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