xref: /openbmc/qemu/docs/sphinx/qapidoc.py (revision b61a4eb3f32ce74c5ffe001806f9e786788a546f)
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
27import os
28import re
29import sys
30import textwrap
31from typing import List
32
33from docutils import nodes
34from docutils.parsers.rst import Directive, directives
35from docutils.statemachine import ViewList
36from qapi.error import QAPIError, QAPISemError
37from qapi.gen import QAPISchemaVisitor
38from qapi.schema import QAPISchema
39
40from sphinx import addnodes
41from sphinx.directives.code import CodeBlock
42from sphinx.errors import ExtensionError
43from sphinx.util.docutils import switch_source_input
44from sphinx.util.nodes import nested_parse_with_titles
45
46
47__version__ = "1.0"
48
49
50def dedent(text: str) -> str:
51    # Adjust indentation to make description text parse as paragraph.
52
53    lines = text.splitlines(True)
54    if re.match(r"\s+", lines[0]):
55        # First line is indented; description started on the line after
56        # the name. dedent the whole block.
57        return textwrap.dedent(text)
58
59    # Descr started on same line. Dedent line 2+.
60    return lines[0] + textwrap.dedent("".join(lines[1:]))
61
62
63# Disable black auto-formatter until re-enabled:
64# fmt: off
65
66
67class QAPISchemaGenRSTVisitor(QAPISchemaVisitor):
68    """A QAPI schema visitor which generates docutils/Sphinx nodes
69
70    This class builds up a tree of docutils/Sphinx nodes corresponding
71    to documentation for the various QAPI objects. To use it, first
72    create a QAPISchemaGenRSTVisitor object, and call its
73    visit_begin() method.  Then you can call one of the two methods
74    'freeform' (to add documentation for a freeform documentation
75    chunk) or 'symbol' (to add documentation for a QAPI symbol). These
76    will cause the visitor to build up the tree of document
77    nodes. Once you've added all the documentation via 'freeform' and
78    'symbol' method calls, you can call 'get_document_nodes' to get
79    the final list of document nodes (in a form suitable for returning
80    from a Sphinx directive's 'run' method).
81    """
82    def __init__(self, sphinx_directive):
83        self._cur_doc = None
84        self._sphinx_directive = sphinx_directive
85        self._top_node = nodes.section()
86        self._active_headings = [self._top_node]
87
88    def _make_dlitem(self, term, defn):
89        """Return a dlitem node with the specified term and definition.
90
91        term should be a list of Text and literal nodes.
92        defn should be one of:
93        - a string, which will be handed to _parse_text_into_node
94        - a list of Text and literal nodes, which will be put into
95          a paragraph node
96        """
97        dlitem = nodes.definition_list_item()
98        dlterm = nodes.term('', '', *term)
99        dlitem += dlterm
100        if defn:
101            dldef = nodes.definition()
102            if isinstance(defn, list):
103                dldef += nodes.paragraph('', '', *defn)
104            else:
105                self._parse_text_into_node(defn, dldef)
106            dlitem += dldef
107        return dlitem
108
109    def _make_section(self, title):
110        """Return a section node with optional title"""
111        section = nodes.section(ids=[self._sphinx_directive.new_serialno()])
112        if title:
113            section += nodes.title(title, title)
114        return section
115
116    def _nodes_for_ifcond(self, ifcond, with_if=True):
117        """Return list of Text, literal nodes for the ifcond
118
119        Return a list which gives text like ' (If: condition)'.
120        If with_if is False, we don't return the "(If: " and ")".
121        """
122
123        doc = ifcond.docgen()
124        if not doc:
125            return []
126        doc = nodes.literal('', doc)
127        if not with_if:
128            return [doc]
129
130        nodelist = [nodes.Text(' ('), nodes.strong('', 'If: ')]
131        nodelist.append(doc)
132        nodelist.append(nodes.Text(')'))
133        return nodelist
134
135    def _nodes_for_one_member(self, member):
136        """Return list of Text, literal nodes for this member
137
138        Return a list of doctree nodes which give text like
139        'name: type (optional) (If: ...)' suitable for use as the
140        'term' part of a definition list item.
141        """
142        term = [nodes.literal('', member.name)]
143        if member.type.doc_type():
144            term.append(nodes.Text(': '))
145            term.append(nodes.literal('', member.type.doc_type()))
146        if member.optional:
147            term.append(nodes.Text(' (optional)'))
148        if member.ifcond.is_present():
149            term.extend(self._nodes_for_ifcond(member.ifcond))
150        return term
151
152    def _nodes_for_variant_when(self, branches, variant):
153        """Return list of Text, literal nodes for variant 'when' clause
154
155        Return a list of doctree nodes which give text like
156        'when tagname is variant (If: ...)' suitable for use in
157        the 'branches' part of a definition list.
158        """
159        term = [nodes.Text(' when '),
160                nodes.literal('', branches.tag_member.name),
161                nodes.Text(' is '),
162                nodes.literal('', '"%s"' % variant.name)]
163        if variant.ifcond.is_present():
164            term.extend(self._nodes_for_ifcond(variant.ifcond))
165        return term
166
167    def _nodes_for_members(self, doc, what, base=None, branches=None):
168        """Return list of doctree nodes for the table of members"""
169        dlnode = nodes.definition_list()
170        for section in doc.args.values():
171            term = self._nodes_for_one_member(section.member)
172            # TODO drop fallbacks when undocumented members are outlawed
173            if section.text:
174                defn = dedent(section.text)
175            else:
176                defn = [nodes.Text('Not documented')]
177
178            dlnode += self._make_dlitem(term, defn)
179
180        if base:
181            dlnode += self._make_dlitem([nodes.Text('The members of '),
182                                         nodes.literal('', base.doc_type())],
183                                        None)
184
185        if branches:
186            for v in branches.variants:
187                if v.type.name == 'q_empty':
188                    continue
189                assert not v.type.is_implicit()
190                term = [nodes.Text('The members of '),
191                        nodes.literal('', v.type.doc_type())]
192                term.extend(self._nodes_for_variant_when(branches, v))
193                dlnode += self._make_dlitem(term, None)
194
195        if not dlnode.children:
196            return []
197
198        section = self._make_section(what)
199        section += dlnode
200        return [section]
201
202    def _nodes_for_enum_values(self, doc):
203        """Return list of doctree nodes for the table of enum values"""
204        seen_item = False
205        dlnode = nodes.definition_list()
206        for section in doc.args.values():
207            termtext = [nodes.literal('', section.member.name)]
208            if section.member.ifcond.is_present():
209                termtext.extend(self._nodes_for_ifcond(section.member.ifcond))
210            # TODO drop fallbacks when undocumented members are outlawed
211            if section.text:
212                defn = dedent(section.text)
213            else:
214                defn = [nodes.Text('Not documented')]
215
216            dlnode += self._make_dlitem(termtext, defn)
217            seen_item = True
218
219        if not seen_item:
220            return []
221
222        section = self._make_section('Values')
223        section += dlnode
224        return [section]
225
226    def _nodes_for_arguments(self, doc, arg_type):
227        """Return list of doctree nodes for the arguments section"""
228        if arg_type and not arg_type.is_implicit():
229            assert not doc.args
230            section = self._make_section('Arguments')
231            dlnode = nodes.definition_list()
232            dlnode += self._make_dlitem(
233                [nodes.Text('The members of '),
234                 nodes.literal('', arg_type.name)],
235                None)
236            section += dlnode
237            return [section]
238
239        return self._nodes_for_members(doc, 'Arguments')
240
241    def _nodes_for_features(self, doc):
242        """Return list of doctree nodes for the table of features"""
243        seen_item = False
244        dlnode = nodes.definition_list()
245        for section in doc.features.values():
246            dlnode += self._make_dlitem(
247                [nodes.literal('', section.member.name)], dedent(section.text))
248            seen_item = True
249
250        if not seen_item:
251            return []
252
253        section = self._make_section('Features')
254        section += dlnode
255        return [section]
256
257    def _nodes_for_example(self, exampletext):
258        """Return list of doctree nodes for a code example snippet"""
259        return [nodes.literal_block(exampletext, exampletext)]
260
261    def _nodes_for_sections(self, doc):
262        """Return list of doctree nodes for additional sections"""
263        nodelist = []
264        for section in doc.sections:
265            if section.tag and section.tag == 'TODO':
266                # Hide TODO: sections
267                continue
268
269            if not section.tag:
270                # Sphinx cannot handle sectionless titles;
271                # Instead, just append the results to the prior section.
272                container = nodes.container()
273                self._parse_text_into_node(section.text, container)
274                nodelist += container.children
275                continue
276
277            snode = self._make_section(section.tag)
278            if section.tag.startswith('Example'):
279                snode += self._nodes_for_example(dedent(section.text))
280            else:
281                self._parse_text_into_node(dedent(section.text), snode)
282            nodelist.append(snode)
283        return nodelist
284
285    def _nodes_for_if_section(self, ifcond):
286        """Return list of doctree nodes for the "If" section"""
287        nodelist = []
288        if ifcond.is_present():
289            snode = self._make_section('If')
290            snode += nodes.paragraph(
291                '', '', *self._nodes_for_ifcond(ifcond, with_if=False)
292            )
293            nodelist.append(snode)
294        return nodelist
295
296    def _add_doc(self, typ, sections):
297        """Add documentation for a command/object/enum...
298
299        We assume we're documenting the thing defined in self._cur_doc.
300        typ is the type of thing being added ("Command", "Object", etc)
301
302        sections is a list of nodes for sections to add to the definition.
303        """
304
305        doc = self._cur_doc
306        snode = nodes.section(ids=[self._sphinx_directive.new_serialno()])
307        snode += nodes.title('', '', *[nodes.literal(doc.symbol, doc.symbol),
308                                       nodes.Text(' (' + typ + ')')])
309        self._parse_text_into_node(doc.body.text, snode)
310        for s in sections:
311            if s is not None:
312                snode += s
313        self._add_node_to_current_heading(snode)
314
315    def visit_enum_type(self, name, info, ifcond, features, members, prefix):
316        doc = self._cur_doc
317        self._add_doc('Enum',
318                      self._nodes_for_enum_values(doc)
319                      + self._nodes_for_features(doc)
320                      + self._nodes_for_sections(doc)
321                      + self._nodes_for_if_section(ifcond))
322
323    def visit_object_type(self, name, info, ifcond, features,
324                          base, members, branches):
325        doc = self._cur_doc
326        if base and base.is_implicit():
327            base = None
328        self._add_doc('Object',
329                      self._nodes_for_members(doc, 'Members', base, branches)
330                      + self._nodes_for_features(doc)
331                      + self._nodes_for_sections(doc)
332                      + self._nodes_for_if_section(ifcond))
333
334    def visit_alternate_type(self, name, info, ifcond, features,
335                             alternatives):
336        doc = self._cur_doc
337        self._add_doc('Alternate',
338                      self._nodes_for_members(doc, 'Members')
339                      + self._nodes_for_features(doc)
340                      + self._nodes_for_sections(doc)
341                      + self._nodes_for_if_section(ifcond))
342
343    def visit_command(self, name, info, ifcond, features, arg_type,
344                      ret_type, gen, success_response, boxed, allow_oob,
345                      allow_preconfig, coroutine):
346        doc = self._cur_doc
347        self._add_doc('Command',
348                      self._nodes_for_arguments(doc, arg_type)
349                      + self._nodes_for_features(doc)
350                      + self._nodes_for_sections(doc)
351                      + self._nodes_for_if_section(ifcond))
352
353    def visit_event(self, name, info, ifcond, features, arg_type, boxed):
354        doc = self._cur_doc
355        self._add_doc('Event',
356                      self._nodes_for_arguments(doc, arg_type)
357                      + self._nodes_for_features(doc)
358                      + self._nodes_for_sections(doc)
359                      + self._nodes_for_if_section(ifcond))
360
361    def symbol(self, doc, entity):
362        """Add documentation for one symbol to the document tree
363
364        This is the main entry point which causes us to add documentation
365        nodes for a symbol (which could be a 'command', 'object', 'event',
366        etc). We do this by calling 'visit' on the schema entity, which
367        will then call back into one of our visit_* methods, depending
368        on what kind of thing this symbol is.
369        """
370        self._cur_doc = doc
371        entity.visit(self)
372        self._cur_doc = None
373
374    def _start_new_heading(self, heading, level):
375        """Start a new heading at the specified heading level
376
377        Create a new section whose title is 'heading' and which is placed
378        in the docutils node tree as a child of the most recent level-1
379        heading. Subsequent document sections (commands, freeform doc chunks,
380        etc) will be placed as children of this new heading section.
381        """
382        if len(self._active_headings) < level:
383            raise QAPISemError(self._cur_doc.info,
384                               'Level %d subheading found outside a '
385                               'level %d heading'
386                               % (level, level - 1))
387        snode = self._make_section(heading)
388        self._active_headings[level - 1] += snode
389        self._active_headings = self._active_headings[:level]
390        self._active_headings.append(snode)
391        return snode
392
393    def _add_node_to_current_heading(self, node):
394        """Add the node to whatever the current active heading is"""
395        self._active_headings[-1] += node
396
397    def freeform(self, doc):
398        """Add a piece of 'freeform' documentation to the document tree
399
400        A 'freeform' document chunk doesn't relate to any particular
401        symbol (for instance, it could be an introduction).
402
403        If the freeform document starts with a line of the form
404        '= Heading text', this is a section or subsection heading, with
405        the heading level indicated by the number of '=' signs.
406        """
407
408        # QAPIDoc documentation says free-form documentation blocks
409        # must have only a body section, nothing else.
410        assert not doc.sections
411        assert not doc.args
412        assert not doc.features
413        self._cur_doc = doc
414
415        text = doc.body.text
416        if re.match(r'=+ ', text):
417            # Section/subsection heading (if present, will always be
418            # the first line of the block)
419            (heading, _, text) = text.partition('\n')
420            (leader, _, heading) = heading.partition(' ')
421            node = self._start_new_heading(heading, len(leader))
422            if text == '':
423                return
424        else:
425            node = nodes.container()
426
427        self._parse_text_into_node(text, node)
428        self._cur_doc = None
429
430    def _parse_text_into_node(self, doctext, node):
431        """Parse a chunk of QAPI-doc-format text into the node
432
433        The doc comment can contain most inline rST markup, including
434        bulleted and enumerated lists.
435        As an extra permitted piece of markup, @var will be turned
436        into ``var``.
437        """
438
439        # Handle the "@var means ``var`` case
440        doctext = re.sub(r'@([\w-]+)', r'``\1``', doctext)
441
442        rstlist = ViewList()
443        for line in doctext.splitlines():
444            # The reported line number will always be that of the start line
445            # of the doc comment, rather than the actual location of the error.
446            # Being more precise would require overhaul of the QAPIDoc class
447            # to track lines more exactly within all the sub-parts of the doc
448            # comment, as well as counting lines here.
449            rstlist.append(line, self._cur_doc.info.fname,
450                           self._cur_doc.info.line)
451        # Append a blank line -- in some cases rST syntax errors get
452        # attributed to the line after one with actual text, and if there
453        # isn't anything in the ViewList corresponding to that then Sphinx
454        # 1.6's AutodocReporter will then misidentify the source/line location
455        # in the error message (usually attributing it to the top-level
456        # .rst file rather than the offending .json file). The extra blank
457        # line won't affect the rendered output.
458        rstlist.append("", self._cur_doc.info.fname, self._cur_doc.info.line)
459        self._sphinx_directive.do_parse(rstlist, node)
460
461    def get_document_nodes(self):
462        """Return the list of docutils nodes which make up the document"""
463        return self._top_node.children
464
465
466# Turn the black formatter on for the rest of the file.
467# fmt: on
468
469
470class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
471    """A QAPI schema visitor which adds Sphinx dependencies each module
472
473    This class calls the Sphinx note_dependency() function to tell Sphinx
474    that the generated documentation output depends on the input
475    schema file associated with each module in the QAPI input.
476    """
477
478    def __init__(self, env, qapidir):
479        self._env = env
480        self._qapidir = qapidir
481
482    def visit_module(self, name):
483        if name != "./builtin":
484            qapifile = self._qapidir + "/" + name
485            self._env.note_dependency(os.path.abspath(qapifile))
486        super().visit_module(name)
487
488
489class NestedDirective(Directive):
490    def run(self):
491        raise NotImplementedError
492
493    def do_parse(self, rstlist, node):
494        """
495        Parse rST source lines and add them to the specified node
496
497        Take the list of rST source lines rstlist, parse them as
498        rST, and add the resulting docutils nodes as children of node.
499        The nodes are parsed in a way that allows them to include
500        subheadings (titles) without confusing the rendering of
501        anything else.
502        """
503        with switch_source_input(self.state, rstlist):
504            nested_parse_with_titles(self.state, rstlist, node)
505
506
507class QAPIDocDirective(NestedDirective):
508    """Extract documentation from the specified QAPI .json file"""
509
510    required_argument = 1
511    optional_arguments = 1
512    option_spec = {"qapifile": directives.unchanged_required}
513    has_content = False
514
515    def new_serialno(self):
516        """Return a unique new ID string suitable for use as a node's ID"""
517        env = self.state.document.settings.env
518        return "qapidoc-%d" % env.new_serialno("qapidoc")
519
520    def run(self):
521        env = self.state.document.settings.env
522        qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
523        qapidir = os.path.dirname(qapifile)
524
525        try:
526            schema = QAPISchema(qapifile)
527
528            # First tell Sphinx about all the schema files that the
529            # output documentation depends on (including 'qapifile' itself)
530            schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
531
532            vis = QAPISchemaGenRSTVisitor(self)
533            vis.visit_begin(schema)
534            for doc in schema.docs:
535                if doc.symbol:
536                    vis.symbol(doc, schema.lookup_entity(doc.symbol))
537                else:
538                    vis.freeform(doc)
539            return vis.get_document_nodes()
540        except QAPIError as err:
541            # Launder QAPI parse errors into Sphinx extension errors
542            # so they are displayed nicely to the user
543            raise ExtensionError(str(err)) from err
544
545
546class QMPExample(CodeBlock, NestedDirective):
547    """
548    Custom admonition for QMP code examples.
549
550    When the :annotated: option is present, the body of this directive
551    is parsed as normal rST, but with any '::' code blocks set to use
552    the QMP lexer. Code blocks must be explicitly written by the user,
553    but this allows for intermingling explanatory paragraphs with
554    arbitrary rST syntax and code blocks for more involved examples.
555
556    When :annotated: is absent, the directive body is treated as a
557    simple standalone QMP code block literal.
558    """
559
560    required_argument = 0
561    optional_arguments = 0
562    has_content = True
563    option_spec = {
564        "annotated": directives.flag,
565        "title": directives.unchanged,
566    }
567
568    def _highlightlang(self) -> addnodes.highlightlang:
569        """Return the current highlightlang setting for the document"""
570        node = None
571        doc = self.state.document
572
573        if hasattr(doc, "findall"):
574            # docutils >= 0.18.1
575            for node in doc.findall(addnodes.highlightlang):
576                pass
577        else:
578            for elem in doc.traverse():
579                if isinstance(elem, addnodes.highlightlang):
580                    node = elem
581
582        if node:
583            return node
584
585        # No explicit directive found, use defaults
586        node = addnodes.highlightlang(
587            lang=self.env.config.highlight_language,
588            force=False,
589            # Yes, Sphinx uses this value to effectively disable line
590            # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
591            linenothreshold=sys.maxsize,
592        )
593        return node
594
595    def admonition_wrap(self, *content) -> List[nodes.Node]:
596        title = "Example:"
597        if "title" in self.options:
598            title = f"{title} {self.options['title']}"
599
600        admon = nodes.admonition(
601            "",
602            nodes.title("", title),
603            *content,
604            classes=["admonition", "admonition-example"],
605        )
606        return [admon]
607
608    def run_annotated(self) -> List[nodes.Node]:
609        lang_node = self._highlightlang()
610
611        content_node: nodes.Element = nodes.section()
612
613        # Configure QMP highlighting for "::" blocks, if needed
614        if lang_node["lang"] != "QMP":
615            content_node += addnodes.highlightlang(
616                lang="QMP",
617                force=False,  # "True" ignores lexing errors
618                linenothreshold=lang_node["linenothreshold"],
619            )
620
621        self.do_parse(self.content, content_node)
622
623        # Restore prior language highlighting, if needed
624        if lang_node["lang"] != "QMP":
625            content_node += addnodes.highlightlang(**lang_node.attributes)
626
627        return content_node.children
628
629    def run(self) -> List[nodes.Node]:
630        annotated = "annotated" in self.options
631
632        if annotated:
633            content_nodes = self.run_annotated()
634        else:
635            self.arguments = ["QMP"]
636            content_nodes = super().run()
637
638        return self.admonition_wrap(*content_nodes)
639
640
641def setup(app):
642    """Register qapi-doc directive with Sphinx"""
643    app.add_config_value("qapidoc_srctree", None, "env")
644    app.add_directive("qapi-doc", QAPIDocDirective)
645    app.add_directive("qmp-example", QMPExample)
646
647    return {
648        "version": __version__,
649        "parallel_read_safe": True,
650        "parallel_write_safe": True,
651    }
652