xref: /openbmc/qemu/scripts/qapi/parser.py (revision e6e03dcf)
1# -*- coding: utf-8 -*-
2#
3# QAPI schema parser
4#
5# Copyright IBM, Corp. 2011
6# Copyright (c) 2013-2019 Red Hat Inc.
7#
8# Authors:
9#  Anthony Liguori <aliguori@us.ibm.com>
10#  Markus Armbruster <armbru@redhat.com>
11#  Marc-André Lureau <marcandre.lureau@redhat.com>
12#  Kevin Wolf <kwolf@redhat.com>
13#
14# This work is licensed under the terms of the GNU GPL, version 2.
15# See the COPYING file in the top-level directory.
16
17import os
18import re
19import sys
20from collections import OrderedDict
21
22from qapi.error import QAPIParseError, QAPISemError
23from qapi.source import QAPISourceInfo
24
25
26class QAPISchemaParser(object):
27
28    def __init__(self, fname, previously_included=None, incl_info=None):
29        previously_included = previously_included or set()
30        previously_included.add(os.path.abspath(fname))
31
32        try:
33            if sys.version_info[0] >= 3:
34                fp = open(fname, 'r', encoding='utf-8')
35            else:
36                fp = open(fname, 'r')
37            self.src = fp.read()
38        except IOError as e:
39            raise QAPISemError(incl_info or QAPISourceInfo(None, None, None),
40                               "can't read %s file '%s': %s"
41                               % ("include" if incl_info else "schema",
42                                  fname,
43                                  e.strerror))
44
45        if self.src == '' or self.src[-1] != '\n':
46            self.src += '\n'
47        self.cursor = 0
48        self.info = QAPISourceInfo(fname, 1, incl_info)
49        self.line_pos = 0
50        self.exprs = []
51        self.docs = []
52        self.accept()
53        cur_doc = None
54
55        while self.tok is not None:
56            info = self.info
57            if self.tok == '#':
58                self.reject_expr_doc(cur_doc)
59                cur_doc = self.get_doc(info)
60                self.docs.append(cur_doc)
61                continue
62
63            expr = self.get_expr(False)
64            if 'include' in expr:
65                self.reject_expr_doc(cur_doc)
66                if len(expr) != 1:
67                    raise QAPISemError(info, "invalid 'include' directive")
68                include = expr['include']
69                if not isinstance(include, str):
70                    raise QAPISemError(info,
71                                       "value of 'include' must be a string")
72                incl_fname = os.path.join(os.path.dirname(fname),
73                                          include)
74                self.exprs.append({'expr': {'include': incl_fname},
75                                   'info': info})
76                exprs_include = self._include(include, info, incl_fname,
77                                              previously_included)
78                if exprs_include:
79                    self.exprs.extend(exprs_include.exprs)
80                    self.docs.extend(exprs_include.docs)
81            elif "pragma" in expr:
82                self.reject_expr_doc(cur_doc)
83                if len(expr) != 1:
84                    raise QAPISemError(info, "invalid 'pragma' directive")
85                pragma = expr['pragma']
86                if not isinstance(pragma, dict):
87                    raise QAPISemError(
88                        info, "value of 'pragma' must be an object")
89                for name, value in pragma.items():
90                    self._pragma(name, value, info)
91            else:
92                expr_elem = {'expr': expr,
93                             'info': info}
94                if cur_doc:
95                    if not cur_doc.symbol:
96                        raise QAPISemError(
97                            cur_doc.info, "definition documentation required")
98                    expr_elem['doc'] = cur_doc
99                self.exprs.append(expr_elem)
100            cur_doc = None
101        self.reject_expr_doc(cur_doc)
102
103    @staticmethod
104    def reject_expr_doc(doc):
105        if doc and doc.symbol:
106            raise QAPISemError(
107                doc.info,
108                "documentation for '%s' is not followed by the definition"
109                % doc.symbol)
110
111    def _include(self, include, info, incl_fname, previously_included):
112        incl_abs_fname = os.path.abspath(incl_fname)
113        # catch inclusion cycle
114        inf = info
115        while inf:
116            if incl_abs_fname == os.path.abspath(inf.fname):
117                raise QAPISemError(info, "inclusion loop for %s" % include)
118            inf = inf.parent
119
120        # skip multiple include of the same file
121        if incl_abs_fname in previously_included:
122            return None
123
124        return QAPISchemaParser(incl_fname, previously_included, info)
125
126    def _pragma(self, name, value, info):
127        if name == 'doc-required':
128            if not isinstance(value, bool):
129                raise QAPISemError(info,
130                                   "pragma 'doc-required' must be boolean")
131            info.pragma.doc_required = value
132        elif name == 'returns-whitelist':
133            if (not isinstance(value, list)
134                    or any([not isinstance(elt, str) for elt in value])):
135                raise QAPISemError(
136                    info,
137                    "pragma returns-whitelist must be a list of strings")
138            info.pragma.returns_whitelist = value
139        elif name == 'name-case-whitelist':
140            if (not isinstance(value, list)
141                    or any([not isinstance(elt, str) for elt in value])):
142                raise QAPISemError(
143                    info,
144                    "pragma name-case-whitelist must be a list of strings")
145            info.pragma.name_case_whitelist = value
146        else:
147            raise QAPISemError(info, "unknown pragma '%s'" % name)
148
149    def accept(self, skip_comment=True):
150        while True:
151            self.tok = self.src[self.cursor]
152            self.pos = self.cursor
153            self.cursor += 1
154            self.val = None
155
156            if self.tok == '#':
157                if self.src[self.cursor] == '#':
158                    # Start of doc comment
159                    skip_comment = False
160                self.cursor = self.src.find('\n', self.cursor)
161                if not skip_comment:
162                    self.val = self.src[self.pos:self.cursor]
163                    return
164            elif self.tok in '{}:,[]':
165                return
166            elif self.tok == "'":
167                # Note: we accept only printable ASCII
168                string = ''
169                esc = False
170                while True:
171                    ch = self.src[self.cursor]
172                    self.cursor += 1
173                    if ch == '\n':
174                        raise QAPIParseError(self, "missing terminating \"'\"")
175                    if esc:
176                        # Note: we recognize only \\ because we have
177                        # no use for funny characters in strings
178                        if ch != '\\':
179                            raise QAPIParseError(self,
180                                                 "unknown escape \\%s" % ch)
181                        esc = False
182                    elif ch == '\\':
183                        esc = True
184                        continue
185                    elif ch == "'":
186                        self.val = string
187                        return
188                    if ord(ch) < 32 or ord(ch) >= 127:
189                        raise QAPIParseError(
190                            self, "funny character in string")
191                    string += ch
192            elif self.src.startswith('true', self.pos):
193                self.val = True
194                self.cursor += 3
195                return
196            elif self.src.startswith('false', self.pos):
197                self.val = False
198                self.cursor += 4
199                return
200            elif self.tok == '\n':
201                if self.cursor == len(self.src):
202                    self.tok = None
203                    return
204                self.info = self.info.next_line()
205                self.line_pos = self.cursor
206            elif not self.tok.isspace():
207                # Show up to next structural, whitespace or quote
208                # character
209                match = re.match('[^[\\]{}:,\\s\'"]+',
210                                 self.src[self.cursor-1:])
211                raise QAPIParseError(self, "stray '%s'" % match.group(0))
212
213    def get_members(self):
214        expr = OrderedDict()
215        if self.tok == '}':
216            self.accept()
217            return expr
218        if self.tok != "'":
219            raise QAPIParseError(self, "expected string or '}'")
220        while True:
221            key = self.val
222            self.accept()
223            if self.tok != ':':
224                raise QAPIParseError(self, "expected ':'")
225            self.accept()
226            if key in expr:
227                raise QAPIParseError(self, "duplicate key '%s'" % key)
228            expr[key] = self.get_expr(True)
229            if self.tok == '}':
230                self.accept()
231                return expr
232            if self.tok != ',':
233                raise QAPIParseError(self, "expected ',' or '}'")
234            self.accept()
235            if self.tok != "'":
236                raise QAPIParseError(self, "expected string")
237
238    def get_values(self):
239        expr = []
240        if self.tok == ']':
241            self.accept()
242            return expr
243        if self.tok not in "{['tfn":
244            raise QAPIParseError(
245                self, "expected '{', '[', ']', string, boolean or 'null'")
246        while True:
247            expr.append(self.get_expr(True))
248            if self.tok == ']':
249                self.accept()
250                return expr
251            if self.tok != ',':
252                raise QAPIParseError(self, "expected ',' or ']'")
253            self.accept()
254
255    def get_expr(self, nested):
256        if self.tok != '{' and not nested:
257            raise QAPIParseError(self, "expected '{'")
258        if self.tok == '{':
259            self.accept()
260            expr = self.get_members()
261        elif self.tok == '[':
262            self.accept()
263            expr = self.get_values()
264        elif self.tok in "'tfn":
265            expr = self.val
266            self.accept()
267        else:
268            raise QAPIParseError(
269                self, "expected '{', '[', string, boolean or 'null'")
270        return expr
271
272    def get_doc(self, info):
273        if self.val != '##':
274            raise QAPIParseError(
275                self, "junk after '##' at start of documentation comment")
276
277        doc = QAPIDoc(self, info)
278        self.accept(False)
279        while self.tok == '#':
280            if self.val.startswith('##'):
281                # End of doc comment
282                if self.val != '##':
283                    raise QAPIParseError(
284                        self,
285                        "junk after '##' at end of documentation comment")
286                doc.end_comment()
287                self.accept()
288                return doc
289            else:
290                doc.append(self.val)
291            self.accept(False)
292
293        raise QAPIParseError(self, "documentation comment must end with '##'")
294
295
296class QAPIDoc(object):
297    """
298    A documentation comment block, either definition or free-form
299
300    Definition documentation blocks consist of
301
302    * a body section: one line naming the definition, followed by an
303      overview (any number of lines)
304
305    * argument sections: a description of each argument (for commands
306      and events) or member (for structs, unions and alternates)
307
308    * features sections: a description of each feature flag
309
310    * additional (non-argument) sections, possibly tagged
311
312    Free-form documentation blocks consist only of a body section.
313    """
314
315    class Section(object):
316        def __init__(self, name=None):
317            # optional section name (argument/member or section name)
318            self.name = name
319            # the list of lines for this section
320            self.text = ''
321
322        def append(self, line):
323            self.text += line.rstrip() + '\n'
324
325    class ArgSection(Section):
326        def __init__(self, name):
327            QAPIDoc.Section.__init__(self, name)
328            self.member = None
329
330        def connect(self, member):
331            self.member = member
332
333    def __init__(self, parser, info):
334        # self._parser is used to report errors with QAPIParseError.  The
335        # resulting error position depends on the state of the parser.
336        # It happens to be the beginning of the comment.  More or less
337        # servicable, but action at a distance.
338        self._parser = parser
339        self.info = info
340        self.symbol = None
341        self.body = QAPIDoc.Section()
342        # dict mapping parameter name to ArgSection
343        self.args = OrderedDict()
344        self.features = OrderedDict()
345        # a list of Section
346        self.sections = []
347        # the current section
348        self._section = self.body
349        self._append_line = self._append_body_line
350
351    def has_section(self, name):
352        """Return True if we have a section with this name."""
353        for i in self.sections:
354            if i.name == name:
355                return True
356        return False
357
358    def append(self, line):
359        """
360        Parse a comment line and add it to the documentation.
361
362        The way that the line is dealt with depends on which part of
363        the documentation we're parsing right now:
364        * The body section: ._append_line is ._append_body_line
365        * An argument section: ._append_line is ._append_args_line
366        * A features section: ._append_line is ._append_features_line
367        * An additional section: ._append_line is ._append_various_line
368        """
369        line = line[1:]
370        if not line:
371            self._append_freeform(line)
372            return
373
374        if line[0] != ' ':
375            raise QAPIParseError(self._parser, "missing space after #")
376        line = line[1:]
377        self._append_line(line)
378
379    def end_comment(self):
380        self._end_section()
381
382    @staticmethod
383    def _is_section_tag(name):
384        return name in ('Returns:', 'Since:',
385                        # those are often singular or plural
386                        'Note:', 'Notes:',
387                        'Example:', 'Examples:',
388                        'TODO:')
389
390    def _append_body_line(self, line):
391        """
392        Process a line of documentation text in the body section.
393
394        If this a symbol line and it is the section's first line, this
395        is a definition documentation block for that symbol.
396
397        If it's a definition documentation block, another symbol line
398        begins the argument section for the argument named by it, and
399        a section tag begins an additional section.  Start that
400        section and append the line to it.
401
402        Else, append the line to the current section.
403        """
404        name = line.split(' ', 1)[0]
405        # FIXME not nice: things like '#  @foo:' and '# @foo: ' aren't
406        # recognized, and get silently treated as ordinary text
407        if not self.symbol and not self.body.text and line.startswith('@'):
408            if not line.endswith(':'):
409                raise QAPIParseError(self._parser, "line should end with ':'")
410            self.symbol = line[1:-1]
411            # FIXME invalid names other than the empty string aren't flagged
412            if not self.symbol:
413                raise QAPIParseError(self._parser, "invalid name")
414        elif self.symbol:
415            # This is a definition documentation block
416            if name.startswith('@') and name.endswith(':'):
417                self._append_line = self._append_args_line
418                self._append_args_line(line)
419            elif line == 'Features:':
420                self._append_line = self._append_features_line
421            elif self._is_section_tag(name):
422                self._append_line = self._append_various_line
423                self._append_various_line(line)
424            else:
425                self._append_freeform(line.strip())
426        else:
427            # This is a free-form documentation block
428            self._append_freeform(line.strip())
429
430    def _append_args_line(self, line):
431        """
432        Process a line of documentation text in an argument section.
433
434        A symbol line begins the next argument section, a section tag
435        section or a non-indented line after a blank line begins an
436        additional section.  Start that section and append the line to
437        it.
438
439        Else, append the line to the current section.
440
441        """
442        name = line.split(' ', 1)[0]
443
444        if name.startswith('@') and name.endswith(':'):
445            line = line[len(name)+1:]
446            self._start_args_section(name[1:-1])
447        elif self._is_section_tag(name):
448            self._append_line = self._append_various_line
449            self._append_various_line(line)
450            return
451        elif (self._section.text.endswith('\n\n')
452              and line and not line[0].isspace()):
453            if line == 'Features:':
454                self._append_line = self._append_features_line
455            else:
456                self._start_section()
457                self._append_line = self._append_various_line
458                self._append_various_line(line)
459            return
460
461        self._append_freeform(line.strip())
462
463    def _append_features_line(self, line):
464        name = line.split(' ', 1)[0]
465
466        if name.startswith('@') and name.endswith(':'):
467            line = line[len(name)+1:]
468            self._start_features_section(name[1:-1])
469        elif self._is_section_tag(name):
470            self._append_line = self._append_various_line
471            self._append_various_line(line)
472            return
473        elif (self._section.text.endswith('\n\n')
474              and line and not line[0].isspace()):
475            self._start_section()
476            self._append_line = self._append_various_line
477            self._append_various_line(line)
478            return
479
480        self._append_freeform(line.strip())
481
482    def _append_various_line(self, line):
483        """
484        Process a line of documentation text in an additional section.
485
486        A symbol line is an error.
487
488        A section tag begins an additional section.  Start that
489        section and append the line to it.
490
491        Else, append the line to the current section.
492        """
493        name = line.split(' ', 1)[0]
494
495        if name.startswith('@') and name.endswith(':'):
496            raise QAPIParseError(self._parser,
497                                 "'%s' can't follow '%s' section"
498                                 % (name, self.sections[0].name))
499        elif self._is_section_tag(name):
500            line = line[len(name)+1:]
501            self._start_section(name[:-1])
502
503        if (not self._section.name or
504                not self._section.name.startswith('Example')):
505            line = line.strip()
506
507        self._append_freeform(line)
508
509    def _start_symbol_section(self, symbols_dict, name):
510        # FIXME invalid names other than the empty string aren't flagged
511        if not name:
512            raise QAPIParseError(self._parser, "invalid parameter name")
513        if name in symbols_dict:
514            raise QAPIParseError(self._parser,
515                                 "'%s' parameter name duplicated" % name)
516        assert not self.sections
517        self._end_section()
518        self._section = QAPIDoc.ArgSection(name)
519        symbols_dict[name] = self._section
520
521    def _start_args_section(self, name):
522        self._start_symbol_section(self.args, name)
523
524    def _start_features_section(self, name):
525        self._start_symbol_section(self.features, name)
526
527    def _start_section(self, name=None):
528        if name in ('Returns', 'Since') and self.has_section(name):
529            raise QAPIParseError(self._parser,
530                                 "duplicated '%s' section" % name)
531        self._end_section()
532        self._section = QAPIDoc.Section(name)
533        self.sections.append(self._section)
534
535    def _end_section(self):
536        if self._section:
537            text = self._section.text = self._section.text.strip()
538            if self._section.name and (not text or text.isspace()):
539                raise QAPIParseError(
540                    self._parser,
541                    "empty doc section '%s'" % self._section.name)
542            self._section = None
543
544    def _append_freeform(self, line):
545        match = re.match(r'(@\S+:)', line)
546        if match:
547            raise QAPIParseError(self._parser,
548                                 "'%s' not allowed in free-form documentation"
549                                 % match.group(1))
550        self._section.append(line)
551
552    def connect_member(self, member):
553        if member.name not in self.args:
554            # Undocumented TODO outlaw
555            self.args[member.name] = QAPIDoc.ArgSection(member.name)
556        self.args[member.name].connect(member)
557
558    def check_expr(self, expr):
559        if self.has_section('Returns') and 'command' not in expr:
560            raise QAPISemError(self.info,
561                               "'Returns:' is only valid for commands")
562
563    def check(self):
564        bogus = [name for name, section in self.args.items()
565                 if not section.member]
566        if bogus:
567            raise QAPISemError(
568                self.info,
569                "the following documented members are not in "
570                "the declaration: %s" % ", ".join(bogus))
571