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