xref: /openbmc/qemu/docs/sphinx/qapidoc.py (revision 646746a1)
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
425        self._parse_text_into_node(text, node)
426        self._cur_doc = None
427
428    def _parse_text_into_node(self, doctext, node):
429        """Parse a chunk of QAPI-doc-format text into the node
430
431        The doc comment can contain most inline rST markup, including
432        bulleted and enumerated lists.
433        As an extra permitted piece of markup, @var will be turned
434        into ``var``.
435        """
436
437        # Handle the "@var means ``var`` case
438        doctext = re.sub(r'@([\w-]+)', r'``\1``', doctext)
439
440        rstlist = ViewList()
441        for line in doctext.splitlines():
442            # The reported line number will always be that of the start line
443            # of the doc comment, rather than the actual location of the error.
444            # Being more precise would require overhaul of the QAPIDoc class
445            # to track lines more exactly within all the sub-parts of the doc
446            # comment, as well as counting lines here.
447            rstlist.append(line, self._cur_doc.info.fname,
448                           self._cur_doc.info.line)
449        # Append a blank line -- in some cases rST syntax errors get
450        # attributed to the line after one with actual text, and if there
451        # isn't anything in the ViewList corresponding to that then Sphinx
452        # 1.6's AutodocReporter will then misidentify the source/line location
453        # in the error message (usually attributing it to the top-level
454        # .rst file rather than the offending .json file). The extra blank
455        # line won't affect the rendered output.
456        rstlist.append("", self._cur_doc.info.fname, self._cur_doc.info.line)
457        self._sphinx_directive.do_parse(rstlist, node)
458
459    def get_document_nodes(self):
460        """Return the list of docutils nodes which make up the document"""
461        return self._top_node.children
462
463
464# Turn the black formatter on for the rest of the file.
465# fmt: on
466
467
468class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
469    """A QAPI schema visitor which adds Sphinx dependencies each module
470
471    This class calls the Sphinx note_dependency() function to tell Sphinx
472    that the generated documentation output depends on the input
473    schema file associated with each module in the QAPI input.
474    """
475
476    def __init__(self, env, qapidir):
477        self._env = env
478        self._qapidir = qapidir
479
480    def visit_module(self, name):
481        if name != "./builtin":
482            qapifile = self._qapidir + "/" + name
483            self._env.note_dependency(os.path.abspath(qapifile))
484        super().visit_module(name)
485
486
487class NestedDirective(Directive):
488    def run(self):
489        raise NotImplementedError
490
491    def do_parse(self, rstlist, node):
492        """
493        Parse rST source lines and add them to the specified node
494
495        Take the list of rST source lines rstlist, parse them as
496        rST, and add the resulting docutils nodes as children of node.
497        The nodes are parsed in a way that allows them to include
498        subheadings (titles) without confusing the rendering of
499        anything else.
500        """
501        with switch_source_input(self.state, rstlist):
502            nested_parse_with_titles(self.state, rstlist, node)
503
504
505class QAPIDocDirective(NestedDirective):
506    """Extract documentation from the specified QAPI .json file"""
507
508    required_argument = 1
509    optional_arguments = 1
510    option_spec = {"qapifile": directives.unchanged_required}
511    has_content = False
512
513    def new_serialno(self):
514        """Return a unique new ID string suitable for use as a node's ID"""
515        env = self.state.document.settings.env
516        return "qapidoc-%d" % env.new_serialno("qapidoc")
517
518    def run(self):
519        env = self.state.document.settings.env
520        qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
521        qapidir = os.path.dirname(qapifile)
522
523        try:
524            schema = QAPISchema(qapifile)
525
526            # First tell Sphinx about all the schema files that the
527            # output documentation depends on (including 'qapifile' itself)
528            schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
529
530            vis = QAPISchemaGenRSTVisitor(self)
531            vis.visit_begin(schema)
532            for doc in schema.docs:
533                if doc.symbol:
534                    vis.symbol(doc, schema.lookup_entity(doc.symbol))
535                else:
536                    vis.freeform(doc)
537            return vis.get_document_nodes()
538        except QAPIError as err:
539            # Launder QAPI parse errors into Sphinx extension errors
540            # so they are displayed nicely to the user
541            raise ExtensionError(str(err)) from err
542
543
544class QMPExample(CodeBlock, NestedDirective):
545    """
546    Custom admonition for QMP code examples.
547
548    When the :annotated: option is present, the body of this directive
549    is parsed as normal rST, but with any '::' code blocks set to use
550    the QMP lexer. Code blocks must be explicitly written by the user,
551    but this allows for intermingling explanatory paragraphs with
552    arbitrary rST syntax and code blocks for more involved examples.
553
554    When :annotated: is absent, the directive body is treated as a
555    simple standalone QMP code block literal.
556    """
557
558    required_argument = 0
559    optional_arguments = 0
560    has_content = True
561    option_spec = {
562        "annotated": directives.flag,
563        "title": directives.unchanged,
564    }
565
566    def _highlightlang(self) -> addnodes.highlightlang:
567        """Return the current highlightlang setting for the document"""
568        node = None
569        doc = self.state.document
570
571        if hasattr(doc, "findall"):
572            # docutils >= 0.18.1
573            for node in doc.findall(addnodes.highlightlang):
574                pass
575        else:
576            for elem in doc.traverse():
577                if isinstance(elem, addnodes.highlightlang):
578                    node = elem
579
580        if node:
581            return node
582
583        # No explicit directive found, use defaults
584        node = addnodes.highlightlang(
585            lang=self.env.config.highlight_language,
586            force=False,
587            # Yes, Sphinx uses this value to effectively disable line
588            # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
589            linenothreshold=sys.maxsize,
590        )
591        return node
592
593    def admonition_wrap(self, *content) -> List[nodes.Node]:
594        title = "Example:"
595        if "title" in self.options:
596            title = f"{title} {self.options['title']}"
597
598        admon = nodes.admonition(
599            "",
600            nodes.title("", title),
601            *content,
602            classes=["admonition", "admonition-example"],
603        )
604        return [admon]
605
606    def run_annotated(self) -> List[nodes.Node]:
607        lang_node = self._highlightlang()
608
609        content_node: nodes.Element = nodes.section()
610
611        # Configure QMP highlighting for "::" blocks, if needed
612        if lang_node["lang"] != "QMP":
613            content_node += addnodes.highlightlang(
614                lang="QMP",
615                force=False,  # "True" ignores lexing errors
616                linenothreshold=lang_node["linenothreshold"],
617            )
618
619        self.do_parse(self.content, content_node)
620
621        # Restore prior language highlighting, if needed
622        if lang_node["lang"] != "QMP":
623            content_node += addnodes.highlightlang(**lang_node.attributes)
624
625        return content_node.children
626
627    def run(self) -> List[nodes.Node]:
628        annotated = "annotated" in self.options
629
630        if annotated:
631            content_nodes = self.run_annotated()
632        else:
633            self.arguments = ["QMP"]
634            content_nodes = super().run()
635
636        return self.admonition_wrap(*content_nodes)
637
638
639def setup(app):
640    """Register qapi-doc directive with Sphinx"""
641    app.add_config_value("qapidoc_srctree", None, "env")
642    app.add_directive("qapi-doc", QAPIDocDirective)
643    app.add_directive("qmp-example", QMPExample)
644
645    return {
646        "version": __version__,
647        "parallel_read_safe": True,
648        "parallel_write_safe": True,
649    }
650