xref: /qemu/docs/sphinx/qapidoc.py (revision 6ff5da16000f908140723e164d33a0b51a6c4162)
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_sections(self, doc):
258        """Return list of doctree nodes for additional sections"""
259        nodelist = []
260        for section in doc.sections:
261            if section.tag and section.tag == 'TODO':
262                # Hide TODO: sections
263                continue
264
265            if not section.tag:
266                # Sphinx cannot handle sectionless titles;
267                # Instead, just append the results to the prior section.
268                container = nodes.container()
269                self._parse_text_into_node(section.text, container)
270                nodelist += container.children
271                continue
272
273            snode = self._make_section(section.tag)
274            self._parse_text_into_node(dedent(section.text), snode)
275            nodelist.append(snode)
276        return nodelist
277
278    def _nodes_for_if_section(self, ifcond):
279        """Return list of doctree nodes for the "If" section"""
280        nodelist = []
281        if ifcond.is_present():
282            snode = self._make_section('If')
283            snode += nodes.paragraph(
284                '', '', *self._nodes_for_ifcond(ifcond, with_if=False)
285            )
286            nodelist.append(snode)
287        return nodelist
288
289    def _add_doc(self, typ, sections):
290        """Add documentation for a command/object/enum...
291
292        We assume we're documenting the thing defined in self._cur_doc.
293        typ is the type of thing being added ("Command", "Object", etc)
294
295        sections is a list of nodes for sections to add to the definition.
296        """
297
298        doc = self._cur_doc
299        snode = nodes.section(ids=[self._sphinx_directive.new_serialno()])
300        snode += nodes.title('', '', *[nodes.literal(doc.symbol, doc.symbol),
301                                       nodes.Text(' (' + typ + ')')])
302        self._parse_text_into_node(doc.body.text, snode)
303        for s in sections:
304            if s is not None:
305                snode += s
306        self._add_node_to_current_heading(snode)
307
308    def visit_enum_type(self, name, info, ifcond, features, members, prefix):
309        doc = self._cur_doc
310        self._add_doc('Enum',
311                      self._nodes_for_enum_values(doc)
312                      + self._nodes_for_features(doc)
313                      + self._nodes_for_sections(doc)
314                      + self._nodes_for_if_section(ifcond))
315
316    def visit_object_type(self, name, info, ifcond, features,
317                          base, members, branches):
318        doc = self._cur_doc
319        if base and base.is_implicit():
320            base = None
321        self._add_doc('Object',
322                      self._nodes_for_members(doc, 'Members', base, branches)
323                      + self._nodes_for_features(doc)
324                      + self._nodes_for_sections(doc)
325                      + self._nodes_for_if_section(ifcond))
326
327    def visit_alternate_type(self, name, info, ifcond, features,
328                             alternatives):
329        doc = self._cur_doc
330        self._add_doc('Alternate',
331                      self._nodes_for_members(doc, 'Members')
332                      + self._nodes_for_features(doc)
333                      + self._nodes_for_sections(doc)
334                      + self._nodes_for_if_section(ifcond))
335
336    def visit_command(self, name, info, ifcond, features, arg_type,
337                      ret_type, gen, success_response, boxed, allow_oob,
338                      allow_preconfig, coroutine):
339        doc = self._cur_doc
340        self._add_doc('Command',
341                      self._nodes_for_arguments(doc, arg_type)
342                      + self._nodes_for_features(doc)
343                      + self._nodes_for_sections(doc)
344                      + self._nodes_for_if_section(ifcond))
345
346    def visit_event(self, name, info, ifcond, features, arg_type, boxed):
347        doc = self._cur_doc
348        self._add_doc('Event',
349                      self._nodes_for_arguments(doc, arg_type)
350                      + self._nodes_for_features(doc)
351                      + self._nodes_for_sections(doc)
352                      + self._nodes_for_if_section(ifcond))
353
354    def symbol(self, doc, entity):
355        """Add documentation for one symbol to the document tree
356
357        This is the main entry point which causes us to add documentation
358        nodes for a symbol (which could be a 'command', 'object', 'event',
359        etc). We do this by calling 'visit' on the schema entity, which
360        will then call back into one of our visit_* methods, depending
361        on what kind of thing this symbol is.
362        """
363        self._cur_doc = doc
364        entity.visit(self)
365        self._cur_doc = None
366
367    def _start_new_heading(self, heading, level):
368        """Start a new heading at the specified heading level
369
370        Create a new section whose title is 'heading' and which is placed
371        in the docutils node tree as a child of the most recent level-1
372        heading. Subsequent document sections (commands, freeform doc chunks,
373        etc) will be placed as children of this new heading section.
374        """
375        if len(self._active_headings) < level:
376            raise QAPISemError(self._cur_doc.info,
377                               'Level %d subheading found outside a '
378                               'level %d heading'
379                               % (level, level - 1))
380        snode = self._make_section(heading)
381        self._active_headings[level - 1] += snode
382        self._active_headings = self._active_headings[:level]
383        self._active_headings.append(snode)
384        return snode
385
386    def _add_node_to_current_heading(self, node):
387        """Add the node to whatever the current active heading is"""
388        self._active_headings[-1] += node
389
390    def freeform(self, doc):
391        """Add a piece of 'freeform' documentation to the document tree
392
393        A 'freeform' document chunk doesn't relate to any particular
394        symbol (for instance, it could be an introduction).
395
396        If the freeform document starts with a line of the form
397        '= Heading text', this is a section or subsection heading, with
398        the heading level indicated by the number of '=' signs.
399        """
400
401        # QAPIDoc documentation says free-form documentation blocks
402        # must have only a body section, nothing else.
403        assert not doc.sections
404        assert not doc.args
405        assert not doc.features
406        self._cur_doc = doc
407
408        text = doc.body.text
409        if re.match(r'=+ ', text):
410            # Section/subsection heading (if present, will always be
411            # the first line of the block)
412            (heading, _, text) = text.partition('\n')
413            (leader, _, heading) = heading.partition(' ')
414            node = self._start_new_heading(heading, len(leader))
415            if text == '':
416                return
417        else:
418            node = nodes.container()
419
420        self._parse_text_into_node(text, node)
421        self._cur_doc = None
422
423    def _parse_text_into_node(self, doctext, node):
424        """Parse a chunk of QAPI-doc-format text into the node
425
426        The doc comment can contain most inline rST markup, including
427        bulleted and enumerated lists.
428        As an extra permitted piece of markup, @var will be turned
429        into ``var``.
430        """
431
432        # Handle the "@var means ``var`` case
433        doctext = re.sub(r'@([\w-]+)', r'``\1``', doctext)
434
435        rstlist = ViewList()
436        for line in doctext.splitlines():
437            # The reported line number will always be that of the start line
438            # of the doc comment, rather than the actual location of the error.
439            # Being more precise would require overhaul of the QAPIDoc class
440            # to track lines more exactly within all the sub-parts of the doc
441            # comment, as well as counting lines here.
442            rstlist.append(line, self._cur_doc.info.fname,
443                           self._cur_doc.info.line)
444        # Append a blank line -- in some cases rST syntax errors get
445        # attributed to the line after one with actual text, and if there
446        # isn't anything in the ViewList corresponding to that then Sphinx
447        # 1.6's AutodocReporter will then misidentify the source/line location
448        # in the error message (usually attributing it to the top-level
449        # .rst file rather than the offending .json file). The extra blank
450        # line won't affect the rendered output.
451        rstlist.append("", self._cur_doc.info.fname, self._cur_doc.info.line)
452        self._sphinx_directive.do_parse(rstlist, node)
453
454    def get_document_nodes(self):
455        """Return the list of docutils nodes which make up the document"""
456        return self._top_node.children
457
458
459# Turn the black formatter on for the rest of the file.
460# fmt: on
461
462
463class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
464    """A QAPI schema visitor which adds Sphinx dependencies each module
465
466    This class calls the Sphinx note_dependency() function to tell Sphinx
467    that the generated documentation output depends on the input
468    schema file associated with each module in the QAPI input.
469    """
470
471    def __init__(self, env, qapidir):
472        self._env = env
473        self._qapidir = qapidir
474
475    def visit_module(self, name):
476        if name != "./builtin":
477            qapifile = self._qapidir + "/" + name
478            self._env.note_dependency(os.path.abspath(qapifile))
479        super().visit_module(name)
480
481
482class NestedDirective(Directive):
483    def run(self):
484        raise NotImplementedError
485
486    def do_parse(self, rstlist, node):
487        """
488        Parse rST source lines and add them to the specified node
489
490        Take the list of rST source lines rstlist, parse them as
491        rST, and add the resulting docutils nodes as children of node.
492        The nodes are parsed in a way that allows them to include
493        subheadings (titles) without confusing the rendering of
494        anything else.
495        """
496        with switch_source_input(self.state, rstlist):
497            nested_parse_with_titles(self.state, rstlist, node)
498
499
500class QAPIDocDirective(NestedDirective):
501    """Extract documentation from the specified QAPI .json file"""
502
503    required_argument = 1
504    optional_arguments = 1
505    option_spec = {"qapifile": directives.unchanged_required}
506    has_content = False
507
508    def new_serialno(self):
509        """Return a unique new ID string suitable for use as a node's ID"""
510        env = self.state.document.settings.env
511        return "qapidoc-%d" % env.new_serialno("qapidoc")
512
513    def run(self):
514        env = self.state.document.settings.env
515        qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
516        qapidir = os.path.dirname(qapifile)
517
518        try:
519            schema = QAPISchema(qapifile)
520
521            # First tell Sphinx about all the schema files that the
522            # output documentation depends on (including 'qapifile' itself)
523            schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
524
525            vis = QAPISchemaGenRSTVisitor(self)
526            vis.visit_begin(schema)
527            for doc in schema.docs:
528                if doc.symbol:
529                    vis.symbol(doc, schema.lookup_entity(doc.symbol))
530                else:
531                    vis.freeform(doc)
532            return vis.get_document_nodes()
533        except QAPIError as err:
534            # Launder QAPI parse errors into Sphinx extension errors
535            # so they are displayed nicely to the user
536            raise ExtensionError(str(err)) from err
537
538
539class QMPExample(CodeBlock, NestedDirective):
540    """
541    Custom admonition for QMP code examples.
542
543    When the :annotated: option is present, the body of this directive
544    is parsed as normal rST, but with any '::' code blocks set to use
545    the QMP lexer. Code blocks must be explicitly written by the user,
546    but this allows for intermingling explanatory paragraphs with
547    arbitrary rST syntax and code blocks for more involved examples.
548
549    When :annotated: is absent, the directive body is treated as a
550    simple standalone QMP code block literal.
551    """
552
553    required_argument = 0
554    optional_arguments = 0
555    has_content = True
556    option_spec = {
557        "annotated": directives.flag,
558        "title": directives.unchanged,
559    }
560
561    def _highlightlang(self) -> addnodes.highlightlang:
562        """Return the current highlightlang setting for the document"""
563        node = None
564        doc = self.state.document
565
566        if hasattr(doc, "findall"):
567            # docutils >= 0.18.1
568            for node in doc.findall(addnodes.highlightlang):
569                pass
570        else:
571            for elem in doc.traverse():
572                if isinstance(elem, addnodes.highlightlang):
573                    node = elem
574
575        if node:
576            return node
577
578        # No explicit directive found, use defaults
579        node = addnodes.highlightlang(
580            lang=self.env.config.highlight_language,
581            force=False,
582            # Yes, Sphinx uses this value to effectively disable line
583            # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
584            linenothreshold=sys.maxsize,
585        )
586        return node
587
588    def admonition_wrap(self, *content) -> List[nodes.Node]:
589        title = "Example:"
590        if "title" in self.options:
591            title = f"{title} {self.options['title']}"
592
593        admon = nodes.admonition(
594            "",
595            nodes.title("", title),
596            *content,
597            classes=["admonition", "admonition-example"],
598        )
599        return [admon]
600
601    def run_annotated(self) -> List[nodes.Node]:
602        lang_node = self._highlightlang()
603
604        content_node: nodes.Element = nodes.section()
605
606        # Configure QMP highlighting for "::" blocks, if needed
607        if lang_node["lang"] != "QMP":
608            content_node += addnodes.highlightlang(
609                lang="QMP",
610                force=False,  # "True" ignores lexing errors
611                linenothreshold=lang_node["linenothreshold"],
612            )
613
614        self.do_parse(self.content, content_node)
615
616        # Restore prior language highlighting, if needed
617        if lang_node["lang"] != "QMP":
618            content_node += addnodes.highlightlang(**lang_node.attributes)
619
620        return content_node.children
621
622    def run(self) -> List[nodes.Node]:
623        annotated = "annotated" in self.options
624
625        if annotated:
626            content_nodes = self.run_annotated()
627        else:
628            self.arguments = ["QMP"]
629            content_nodes = super().run()
630
631        return self.admonition_wrap(*content_nodes)
632
633
634def setup(app):
635    """Register qapi-doc directive with Sphinx"""
636    app.add_config_value("qapidoc_srctree", None, "env")
637    app.add_directive("qapi-doc", QAPIDocDirective)
638    app.add_directive("qmp-example", QMPExample)
639
640    return {
641        "version": __version__,
642        "parallel_read_safe": True,
643        "parallel_write_safe": True,
644    }
645