All of lore.kernel.org
 help / color / mirror / Atom feed
From: "Marc-André Lureau" <marcandre.lureau@redhat.com>
To: qemu-devel@nongnu.org
Cc: eblake@redhat.com, armbru@redhat.com,
	"Marc-André Lureau" <marcandre.lureau@redhat.com>
Subject: [Qemu-devel] [PATCH v3 11/14] qapi: add qapi2texi script
Date: Mon,  7 Nov 2016 11:30:30 +0400	[thread overview]
Message-ID: <20161107073033.21025-12-marcandre.lureau@redhat.com> (raw)
In-Reply-To: <20161107073033.21025-1-marcandre.lureau@redhat.com>

As the name suggests, the qapi2texi script converts JSON QAPI
description into a texi file suitable for different target
formats (info/man/txt/pdf/html...).

It parses the following kind of blocks:

Free-form:

  ##
  # = Section
  # == Subsection
  #
  # Some text foo with *emphasis*
  # 1. with a list
  # 2. like that
  #
  # And some code:
  # | $ echo foo
  # | -> do this
  # | <- get that
  #
  ##

Symbol:

  ##
  # @symbol:
  #
  # Symbol body ditto ergo sum. Foo bar
  # baz ding.
  #
  # @arg: foo
  # @arg: #optional foo
  #
  # Returns: returns bla bla
  #          Or bla blah
  #
  # Since: version
  # Notes: notes, comments can have
  #        - itemized list
  #        - like this
  #
  # Example:
  #
  # -> { "execute": "quit" }
  # <- { "return": {} }
  #
  ##

That's roughly following the following BNF grammar:

api_comment = "##\n" comment "##\n"
comment = freeform_comment | symbol_comment
freeform_comment = { "#" text "\n" }
symbol_comment = "#" "@" name ":\n" { freeform | member | meta }
member = "#" '@' name ':' [ text ] freeform_comment
meta = "#" ( "Returns:", "Since:", "Note:", "Notes:", "Example:", "Examples:" ) [ text ] freeform_comment
text = free-text markdown-like, "#optional" for members

Thanks to the following json expressions, the documentation is enhanced
with extra information about the type of arguments and return value
expected.

Signed-off-by: Marc-André Lureau <marcandre.lureau@redhat.com>
---
 scripts/qapi.py        | 175 ++++++++++++++++++++++++++-
 scripts/qapi2texi.py   | 316 +++++++++++++++++++++++++++++++++++++++++++++++++
 docs/qapi-code-gen.txt |  44 +++++--
 3 files changed, 524 insertions(+), 11 deletions(-)
 create mode 100755 scripts/qapi2texi.py

diff --git a/scripts/qapi.py b/scripts/qapi.py
index 21bc32f..ed52ee4 100644
--- a/scripts/qapi.py
+++ b/scripts/qapi.py
@@ -122,6 +122,103 @@ class QAPIExprError(Exception):
             "%s:%d: %s" % (self.info['file'], self.info['line'], self.msg)
 
 
+class QAPIDoc(object):
+    def __init__(self, parser):
+        self.parser = parser
+        self.symbol = None
+        self.body = []
+        # args is {'arg': 'doc', ...}
+        self.args = OrderedDict()
+        # meta is [(Since/Notes/Examples/Returns:, 'doc'), ...]
+        self.meta = []
+        # the current section to populate, array of [dict, key, comment...]
+        self.section = None
+        self.expr_elem = None
+
+    def get_body(self):
+        return "\n".join(self.body)
+
+    def has_meta(self, name):
+        """Returns True if the doc has a meta section 'name'"""
+        return next((True for i in self.meta if i[0] == name), False)
+
+    def append(self, line):
+        """Adds a # comment line, to be parsed and added in a section"""
+        line = line[1:]
+        if len(line) == 0:
+            self._append_section(line)
+            return
+
+        if line[0] != ' ':
+            raise QAPISchemaError(self.parser, "missing space after #")
+
+        line = line[1:]
+        # take the first word out
+        name = line.split(' ', 1)[0]
+        if name.startswith("@") and name.endswith(":"):
+            line = line[len(name):]
+            name = name[1:-1]
+            if self.symbol is None:
+                # the first is the symbol this APIDoc object documents
+                if len(self.body):
+                    raise QAPISchemaError(self.parser, "symbol must come first")
+                self.symbol = name
+            else:
+                # else an arg
+                self._start_args_section(name)
+        elif self.symbol and name in (
+                "Returns:", "Since:",
+                # those are often singular or plural
+                "Note:", "Notes:",
+                "Example:", "Examples:"):
+            # new "meta" section
+            line = line[len(name):]
+            self._start_meta_section(name[:-1])
+
+        self._append_section(line)
+
+    def _start_args_section(self, name):
+        self.end_section()
+        if self.args.has_key(name):
+            raise QAPISchemaError(self.parser, "'%s' arg duplicated" % name)
+        self.section = [self.args, name]
+
+    def _start_meta_section(self, name):
+        self.end_section()
+        if name in ("Returns", "Since") and self.has_meta(name):
+            raise QAPISchemaError(self.parser, "'%s' section duplicated" % name)
+        self.section = [self.meta, name]
+
+    def _append_section(self, line):
+        """Add a comment to the current section, or the comment body"""
+        if self.section:
+            name = self.section[1]
+            if not name.startswith("Example"):
+                # an empty line ends the section, except with Example
+                if len(self.section) > 2 and len(line) == 0:
+                    self.end_section()
+                    return
+                # Example is verbatim
+                line = line.strip()
+            if len(line) > 0:
+                self.section.append(line)
+        else:
+            self.body.append(line.strip())
+
+    def end_section(self):
+        if self.section is not None:
+            target = self.section[0]
+            name = self.section[1]
+            if len(self.section) < 3:
+                raise QAPISchemaError(self.parser, "Empty doc section")
+            doc = "\n".join(self.section[2:])
+            if isinstance(target, dict):
+                target[name] = doc
+            else:
+                target.append((name, doc))
+            self.section = None
+
+
 class QAPISchemaParser(object):
 
     def __init__(self, fp, previously_included=[], incl_info=None):
@@ -137,9 +234,15 @@ class QAPISchemaParser(object):
         self.line = 1
         self.line_pos = 0
         self.exprs = []
+        self.docs = []
         self.accept()
 
         while self.tok is not None:
+            if self.tok == '#' and self.val.startswith('##'):
+                doc = self.get_doc()
+                self.docs.append(doc)
+                continue
+
             expr_info = {'file': fname, 'line': self.line,
                          'parent': self.incl_info}
             expr = self.get_expr(False)
@@ -160,6 +263,7 @@ class QAPISchemaParser(object):
                         raise QAPIExprError(expr_info, "Inclusion loop for %s"
                                             % include)
                     inf = inf['parent']
+
                 # skip multiple include of the same file
                 if incl_abs_fname in previously_included:
                     continue
@@ -171,12 +275,40 @@ class QAPISchemaParser(object):
                 exprs_include = QAPISchemaParser(fobj, previously_included,
                                                  expr_info)
                 self.exprs.extend(exprs_include.exprs)
+                self.docs.extend(exprs_include.docs)
             else:
                 expr_elem = {'expr': expr,
                              'info': expr_info}
+                if len(self.docs) > 0:
+                    self.docs[-1].expr_elem = expr_elem
                 self.exprs.append(expr_elem)
 
-    def accept(self):
+    def get_doc(self):
+        if self.val != '##':
+            raise QAPISchemaError(self, "Doc comment not starting with '##'")
+
+        doc = QAPIDoc(self)
+        self.accept(False)
+        while self.tok == '#':
+            if self.val.startswith('##'):
+                # ## ends doc
+                if self.val != '##':
+                    raise QAPISchemaError(self, "non-empty '##' line %s"
+                                          % self.val)
+                self.accept()
+                doc.end_section()
+                return doc
+            else:
+                doc.append(self.val)
+            self.accept(False)
+
+        if self.val != '##':
+            raise QAPISchemaError(self, "Doc comment not finishing with '##'")
+
+        doc.end_section()
+        return doc
+
+    def accept(self, skip_comment=True):
         while True:
             self.tok = self.src[self.cursor]
             self.pos = self.cursor
@@ -184,7 +316,13 @@ class QAPISchemaParser(object):
             self.val = None
 
             if self.tok == '#':
+                if self.src[self.cursor] == '#':
+                    # ## starts a doc comment
+                    skip_comment = False
                 self.cursor = self.src.find('\n', self.cursor)
+                self.val = self.src[self.pos:self.cursor]
+                if not skip_comment:
+                    return
             elif self.tok in "{}:,[]":
                 return
             elif self.tok == "'":
@@ -779,6 +917,41 @@ def check_exprs(exprs):
 
     return exprs
 
+def check_docs(docs):
+    for doc in docs:
+        expr_elem = doc.expr_elem
+        if not expr_elem:
+            continue
+
+        expr = expr_elem['expr']
+        for i in ('enum', 'union', 'alternate', 'struct', 'command', 'event'):
+            if i in expr:
+                meta = i
+                break
+
+        info = expr_elem['info']
+        name = expr[meta]
+        if doc.symbol != name:
+            raise QAPIExprError(info,
+                                "Documentation symbol mismatch '%s' != '%s'"
+                                % (doc.symbol, name))
+        if not 'command' in expr and doc.has_meta('Returns'):
+            raise QAPIExprError(info, "Invalid return documentation")
+
+        doc_args = set(doc.args.keys())
+        if meta == 'union':
+            data = expr.get('base', [])
+        else:
+            data = expr.get('data', [])
+        if isinstance(data, dict):
+            data = data.keys()
+        args = set([k.strip('*') for k in data])
+        if meta == 'alternate' or \
+           (meta == 'union' and not expr.get('discriminator')):
+            args.add('type')
+        if not doc_args.issubset(args):
+            raise QAPIExprError(info, "Members documentation is not a subset of"
+                                " API %r > %r" % (list(doc_args), list(args)))
 
 #
 # Schema compiler frontend
diff --git a/scripts/qapi2texi.py b/scripts/qapi2texi.py
new file mode 100755
index 0000000..7e2440c
--- /dev/null
+++ b/scripts/qapi2texi.py
@@ -0,0 +1,316 @@
+#!/usr/bin/env python
+# QAPI texi generator
+#
+# This work is licensed under the terms of the GNU LGPL, version 2+.
+# See the COPYING file in the top-level directory.
+"""This script produces the documentation of a qapi schema in texinfo format"""
+import re
+import sys
+
+from qapi import *
+
+COMMAND_FMT = """
+@deftypefn {type} {{{ret}}} {name} @
+{{{args}}}
+
+{body}
+
+@end deftypefn
+
+""".format
+
+ENUM_FMT = """
+@deftp Enum {name}
+
+{body}
+
+@end deftp
+
+""".format
+
+STRUCT_FMT = """
+@deftp {type} {name} @
+{{{attrs}}}
+
+{body}
+
+@end deftp
+
+""".format
+
+EXAMPLE_FMT = """@example
+{code}
+@end example
+""".format
+
+
+def subst_strong(doc):
+    """Replaces *foo* by @strong{foo}"""
+    return re.sub(r'\*([^_\n]+)\*', r'@emph{\1}', doc)
+
+
+def subst_emph(doc):
+    """Replaces _foo_ by @emph{foo}"""
+    return re.sub(r'\s_([^_\n]+)_\s', r' @emph{\1} ', doc)
+
+
+def subst_vars(doc):
+    """Replaces @var by @var{var}"""
+    return re.sub(r'@([\w-]+)', r'@var{\1}', doc)
+
+
+def subst_braces(doc):
+    """Replaces {} with @{ @}"""
+    return doc.replace("{", "@{").replace("}", "@}")
+
+
+def texi_example(doc):
+    """Format @example"""
+    doc = subst_braces(doc).strip('\n')
+    return EXAMPLE_FMT(code=doc)
+
+
+def texi_comment(doc):
+    """
+    Format a comment
+
+    Lines starting with:
+    - |: generates an @example
+    - =: generates @section
+    - ==: generates @subsection
+    - 1. or 1): generates an @enumerate @item
+    - o/*/-: generates an @itemize list
+    """
+    lines = []
+    doc = subst_braces(doc)
+    doc = subst_vars(doc)
+    doc = subst_emph(doc)
+    doc = subst_strong(doc)
+    inlist = ""
+    lastempty = False
+    for line in doc.split('\n'):
+        empty = line == ""
+
+        if line.startswith("| "):
+            line = EXAMPLE_FMT(code=line[1:])
+        elif line.startswith("= "):
+            line = "@section " + line[1:]
+        elif line.startswith("== "):
+            line = "@subsection " + line[2:]
+        elif re.match("^([0-9]*[.)]) ", line):
+            if not inlist:
+                lines.append("@enumerate")
+                inlist = "enumerate"
+            line = line[line.find(" ")+1:]
+            lines.append("@item")
+        elif re.match("^[o*-] ", line):
+            if not inlist:
+                lines.append("@itemize %s" % {'o': "@bullet",
+                                              '*': "@minus",
+                                              '-': ""}[line[0]])
+                inlist = "itemize"
+            lines.append("@item")
+            line = line[2:]
+        elif lastempty and inlist:
+            lines.append("@end %s\n" % inlist)
+            inlist = ""
+
+        lastempty = empty
+        lines.append(line)
+
+    if inlist:
+        lines.append("@end %s\n" % inlist)
+    return "\n".join(lines)
+
+
+def texi_args(expr):
+    """
+    Format the functions/structure/events.. arguments/members
+    """
+    data = expr["data"] if "data" in expr else {}
+    if isinstance(data, str):
+        args = data
+    else:
+        arg_list = []
+        for name, typ in data.iteritems():
+            # optional arg
+            if name.startswith("*"):
+                name = name[1:]
+                arg_list.append("['%s': @var{%s}]" % (name, typ))
+            # regular arg
+            else:
+                arg_list.append("'%s': @var{%s}" % (name, typ))
+        args = ", ".join(arg_list)
+    return args
+
+def section_order(section):
+    return {"Returns": 0,
+            "Note": 1,
+            "Notes": 1,
+            "Since": 2,
+            "Example": 3,
+            "Examples": 3}[section]
+
+def texi_body(doc, arg="@var"):
+    """
+    Format the body of a symbol documentation:
+    - a table of arguments
+    - followed by "Returns/Notes/Since/Example" sections
+    """
+    body = "@table %s\n" % arg
+    for arg, desc in doc.args.iteritems():
+        if desc.startswith("#optional"):
+            desc = desc[10:]
+            arg += "*"
+        elif desc.endswith("#optional"):
+            desc = desc[:-10]
+            arg += "*"
+        body += "@item %s\n%s\n" % (arg, texi_comment(desc))
+    body += "@end table\n"
+    body += texi_comment(doc.get_body())
+
+    meta = sorted(doc.meta, key=lambda i: section_order(i[0]))
+    for m in meta:
+        key, doc = m
+        func = texi_comment
+        if key.startswith("Example"):
+            func = texi_example
+
+        body += "\n@quotation %s\n%s\n@end quotation" % \
+                (key, func(doc))
+    return body
+
+
+def texi_alternate(expr, doc):
+    """
+    Format an alternate to texi
+    """
+    args = texi_args(expr)
+    body = texi_body(doc)
+    return STRUCT_FMT(type="Alternate",
+                      name=doc.symbol,
+                      attrs="[ " + args + " ]",
+                      body=body)
+
+
+def texi_union(expr, doc):
+    """
+    Format an union to texi
+    """
+    args = texi_args(expr)
+    body = texi_body(doc)
+    return STRUCT_FMT(type="Union",
+                      name=doc.symbol,
+                      attrs="[ " + args + " ]",
+                      body=body)
+
+
+def texi_enum(_, doc):
+    """
+    Format an enum to texi
+    """
+    body = texi_body(doc, "@samp")
+    return ENUM_FMT(name=doc.symbol,
+                    body=body)
+
+
+def texi_struct(expr, doc):
+    """
+    Format a struct to texi
+    """
+    args = texi_args(expr)
+    body = texi_body(doc)
+    return STRUCT_FMT(type="Struct",
+                      name=doc.symbol,
+                      attrs="@{ " + args + " @}",
+                      body=body)
+
+
+def texi_command(expr, doc):
+    """
+    Format a command to texi
+    """
+    args = texi_args(expr)
+    ret = expr["returns"] if "returns" in expr else ""
+    body = texi_body(doc)
+    return COMMAND_FMT(type="Command",
+                       name=doc.symbol,
+                       ret=ret,
+                       args="(" + args + ")",
+                       body=body)
+
+
+def texi_event(expr, doc):
+    """
+    Format an event to texi
+    """
+    args = texi_args(expr)
+    body = texi_body(doc)
+    return COMMAND_FMT(type="Event",
+                       name=doc.symbol,
+                       ret="",
+                       args="(" + args + ")",
+                       body=body)
+
+
+def texi(docs):
+    """
+    Convert QAPI schema expressions to texi documentation
+    """
+    res = []
+    for doc in docs:
+        try:
+            expr_elem = doc.expr_elem
+            if expr_elem is None:
+                res.append(texi_body(doc))
+                continue
+
+            expr = expr_elem['expr']
+            (kind, _) = expr.items()[0]
+
+            fmt = {"command": texi_command,
+                   "struct": texi_struct,
+                   "enum": texi_enum,
+                   "union": texi_union,
+                   "alternate": texi_alternate,
+                   "event": texi_event}
+            try:
+                fmt = fmt[kind]
+            except KeyError:
+                raise ValueError("Unknown expression kind '%s'" % kind)
+            res.append(fmt(expr, doc))
+        except:
+            print >>sys.stderr, "error at @%s" % qapi
+            raise
+
+    return '\n'.join(res)
+
+
+def parse_schema(fname):
+    """
+    Parse the given schema file and return the exprs
+    """
+    try:
+        schema = QAPISchemaParser(open(fname, "r"))
+        check_exprs(schema.exprs)
+        check_docs(schema.docs)
+        return schema.docs
+    except (QAPISchemaError, QAPIExprError), err:
+        print >>sys.stderr, err
+        exit(1)
+
+
+def main(argv):
+    """
+    Takes schema argument, prints result to stdout
+    """
+    if len(argv) != 2:
+        print >>sys.stderr, "%s: need exactly 1 argument: SCHEMA" % argv[0]
+        sys.exit(1)
+
+    docs = parse_schema(argv[1])
+    print texi(docs)
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/docs/qapi-code-gen.txt b/docs/qapi-code-gen.txt
index 2841c51..d82e251 100644
--- a/docs/qapi-code-gen.txt
+++ b/docs/qapi-code-gen.txt
@@ -45,16 +45,13 @@ QAPI parser does not).  At present, there is no place where a QAPI
 schema requires the use of JSON numbers or null.
 
 Comments are allowed; anything between an unquoted # and the following
-newline is ignored.  Although there is not yet a documentation
-generator, a form of stylized comments has developed for consistently
-documenting details about an expression and when it was added to the
-schema.  The documentation is delimited between two lines of ##, then
-the first line names the expression, an optional overview is provided,
-then individual documentation about each member of 'data' is provided,
-and finally, a 'Since: x.y.z' tag lists the release that introduced
-the expression.  Optional members are tagged with the phrase
-'#optional', often with their default value; and extensions added
-after the expression was first released are also given a '(since
+newline is ignored.  The documentation is delimited between two lines
+of ##, then the first line names the expression, an optional overview
+is provided, then individual documentation about each member of 'data'
+is provided, and finally, a 'Since: x.y.z' tag lists the release that
+introduced the expression.  Optional members are tagged with the
+phrase '#optional', often with their default value; and extensions
+added after the expression was first released are also given a '(since
 x.y.z)' comment.  For example:
 
     ##
@@ -73,12 +70,39 @@ x.y.z)' comment.  For example:
     #           (Since 2.0)
     #
     # Since: 0.14.0
+    #
+    # Notes: You can also make a list:
+    #        - with items
+    #        - like this
+    #
+    # Example:
+    #
+    # -> { "execute": ... }
+    # <- { "return": ... }
+    #
     ##
     { 'struct': 'BlockStats',
       'data': {'*device': 'str', 'stats': 'BlockDeviceStats',
                '*parent': 'BlockStats',
                '*backing': 'BlockStats'} }
 
+It's also possible to create documentation sections, such as:
+
+    ##
+    # = Section
+    # == Subsection
+    #
+    # Some text foo with *emphasis*
+    # 1. with a list
+    # 2. like that
+    #
+    # And some code:
+    # | $ echo foo
+    # | -> do this
+    # | <- get that
+    #
+    ##
+
 The schema sets up a series of types, as well as commands and events
 that will use those types.  Forward references are allowed: the parser
 scans in two passes, where the first pass learns all type names, and
-- 
2.10.0

  parent reply	other threads:[~2016-11-07  7:31 UTC|newest]

Thread overview: 28+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2016-11-07  7:30 [Qemu-devel] [PATCH v3 00/14] qapi doc generation (whole version, squashed) Marc-André Lureau
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 01/14] qapi: add missing 'bus' argument in device_add Marc-André Lureau
2016-11-07 15:10   ` Markus Armbruster
2016-11-07 22:55   ` Eric Blake
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 02/14] qga/schema: fix double-return in doc Marc-André Lureau
2016-11-07 22:59   ` Eric Blake
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 03/14] qga/schema: improve guest-set-vcpus Returns: section Marc-André Lureau
2016-11-07 15:25   ` Markus Armbruster
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 04/14] qapi: fix schema symbol sections Marc-André Lureau
2016-11-07 23:07   ` Eric Blake
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 05/14] qapi: fix missing symbol @prefix Marc-André Lureau
2016-11-07 23:08   ` Eric Blake
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 06/14] qapi: fix various symbols mismatch in documentation Marc-André Lureau
2016-11-07 15:40   ` Markus Armbruster
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 07/14] qapi: use one symbol per line Marc-André Lureau
2016-11-07 15:57   ` Markus Armbruster
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 08/14] qapi: add missing colon-ending for section name Marc-André Lureau
2016-11-07 15:58   ` Markus Armbruster
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 09/14] qapi: add some sections in docs Marc-André Lureau
2016-11-07 15:59   ` Markus Armbruster
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 10/14] docs: add master qapi texi files Marc-André Lureau
2016-11-08 14:19   ` Markus Armbruster
2016-11-07  7:30 ` Marc-André Lureau [this message]
2016-11-10 16:37   ` [Qemu-devel] [PATCH v3 11/14] qapi: add qapi2texi script Markus Armbruster
2016-11-15 17:17     ` Marc-André Lureau
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 12/14] texi2pod: learn quotation, deftp and deftypefn Marc-André Lureau
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 13/14] qmp-commands: (SQUASHED) move doc to schema Marc-André Lureau
2016-11-07  7:30 ` [Qemu-devel] [PATCH v3 14/14] build-sys: add qapi doc generation targets Marc-André Lureau

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20161107073033.21025-12-marcandre.lureau@redhat.com \
    --to=marcandre.lureau@redhat.com \
    --cc=armbru@redhat.com \
    --cc=eblake@redhat.com \
    --cc=qemu-devel@nongnu.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.