]>
Commit | Line | Data |
---|---|---|
e6c42b96 MA |
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 | ||
67fea575 | 17 | from collections import OrderedDict |
e6c42b96 MA |
18 | import os |
19 | import re | |
810aff8f | 20 | from typing import ( |
e7ac60fc | 21 | TYPE_CHECKING, |
7c6e4464 | 22 | Any, |
810aff8f JS |
23 | Dict, |
24 | List, | |
42011059 | 25 | Mapping, |
a6c5d159 | 26 | Match, |
810aff8f JS |
27 | Optional, |
28 | Set, | |
29 | Union, | |
30 | ) | |
e6c42b96 | 31 | |
e0e8a0ac | 32 | from .common import must_match |
ac6a7d88 | 33 | from .error import QAPISemError, QAPISourceError |
7137a960 | 34 | from .source import QAPISourceInfo |
e6c42b96 MA |
35 | |
36 | ||
e7ac60fc JS |
37 | if TYPE_CHECKING: |
38 | # pylint: disable=cyclic-import | |
39 | # TODO: Remove cycle. [schema -> expr -> parser -> schema] | |
40 | from .schema import QAPISchemaFeature, QAPISchemaMember | |
41 | ||
42 | ||
810aff8f JS |
43 | # Return value alias for get_expr(). |
44 | _ExprValue = Union[List[object], Dict[str, object], str, bool] | |
45 | ||
42011059 | 46 | |
7c6e4464 | 47 | class QAPIExpression(Dict[str, Any]): |
42011059 JS |
48 | # pylint: disable=too-few-public-methods |
49 | def __init__(self, | |
50 | data: Mapping[str, object], | |
51 | info: QAPISourceInfo, | |
52 | doc: Optional['QAPIDoc'] = None): | |
53 | super().__init__(data) | |
54 | self.info = info | |
55 | self.doc: Optional['QAPIDoc'] = doc | |
15acf48c | 56 | |
810aff8f | 57 | |
ac6a7d88 JS |
58 | class QAPIParseError(QAPISourceError): |
59 | """Error class for all QAPI schema parsing errors.""" | |
810aff8f | 60 | def __init__(self, parser: 'QAPISchemaParser', msg: str): |
ac6a7d88 JS |
61 | col = 1 |
62 | for ch in parser.src[parser.line_pos:parser.pos]: | |
63 | if ch == '\t': | |
64 | col = (col + 7) % 8 + 1 | |
65 | else: | |
66 | col += 1 | |
67 | super().__init__(parser.info, msg, col) | |
68 | ||
69 | ||
baa310f1 | 70 | class QAPISchemaParser: |
d4092ffa JS |
71 | """ |
72 | Parse QAPI schema source. | |
e6c42b96 | 73 | |
d4092ffa | 74 | Parse a JSON-esque schema file and process directives. See |
b0b1313e | 75 | qapi-code-gen.rst section "Schema Syntax" for the exact syntax. |
d4092ffa JS |
76 | Grammatical validation is handled later by `expr.check_exprs()`. |
77 | ||
78 | :param fname: Source file name. | |
79 | :param previously_included: | |
80 | The absolute names of previously included source files, | |
81 | if being invoked from another parser. | |
82 | :param incl_info: | |
83 | `QAPISourceInfo` belonging to the parent module. | |
84 | ``None`` implies this is the root module. | |
85 | ||
86 | :ivar exprs: Resulting parsed expressions. | |
87 | :ivar docs: Resulting parsed documentation blocks. | |
88 | ||
89 | :raise OSError: For problems reading the root schema document. | |
90 | :raise QAPIError: For errors in the schema source. | |
91 | """ | |
810aff8f JS |
92 | def __init__(self, |
93 | fname: str, | |
94 | previously_included: Optional[Set[str]] = None, | |
95 | incl_info: Optional[QAPISourceInfo] = None): | |
16ff40ac JS |
96 | self._fname = fname |
97 | self._included = previously_included or set() | |
98 | self._included.add(os.path.abspath(self._fname)) | |
99 | self.src = '' | |
e6c42b96 | 100 | |
16ff40ac JS |
101 | # Lexer state (see `accept` for details): |
102 | self.info = QAPISourceInfo(self._fname, incl_info) | |
810aff8f | 103 | self.tok: Union[None, str] = None |
16ff40ac | 104 | self.pos = 0 |
e6c42b96 | 105 | self.cursor = 0 |
810aff8f | 106 | self.val: Optional[Union[bool, str]] = None |
e6c42b96 | 107 | self.line_pos = 0 |
16ff40ac JS |
108 | |
109 | # Parser output: | |
42011059 | 110 | self.exprs: List[QAPIExpression] = [] |
810aff8f | 111 | self.docs: List[QAPIDoc] = [] |
16ff40ac JS |
112 | |
113 | # Showtime! | |
114 | self._parse() | |
115 | ||
810aff8f | 116 | def _parse(self) -> None: |
d4092ffa JS |
117 | """ |
118 | Parse the QAPI schema document. | |
119 | ||
120 | :return: None. Results are stored in ``.exprs`` and ``.docs``. | |
121 | """ | |
e6c42b96 MA |
122 | cur_doc = None |
123 | ||
16ff40ac JS |
124 | # May raise OSError; allow the caller to handle it. |
125 | with open(self._fname, 'r', encoding='utf-8') as fp: | |
126 | self.src = fp.read() | |
127 | if self.src == '' or self.src[-1] != '\n': | |
128 | self.src += '\n' | |
129 | ||
130 | # Prime the lexer: | |
131 | self.accept() | |
132 | ||
133 | # Parse until done: | |
e6c42b96 MA |
134 | while self.tok is not None: |
135 | info = self.info | |
136 | if self.tok == '#': | |
137 | self.reject_expr_doc(cur_doc) | |
3d035cd2 MA |
138 | cur_doc = self.get_doc() |
139 | self.docs.append(cur_doc) | |
e6c42b96 MA |
140 | continue |
141 | ||
9cd0205d JS |
142 | expr = self.get_expr() |
143 | if not isinstance(expr, dict): | |
144 | raise QAPISemError( | |
145 | info, "top-level expression must be an object") | |
146 | ||
e6c42b96 MA |
147 | if 'include' in expr: |
148 | self.reject_expr_doc(cur_doc) | |
149 | if len(expr) != 1: | |
150 | raise QAPISemError(info, "invalid 'include' directive") | |
151 | include = expr['include'] | |
152 | if not isinstance(include, str): | |
153 | raise QAPISemError(info, | |
154 | "value of 'include' must be a string") | |
16ff40ac | 155 | incl_fname = os.path.join(os.path.dirname(self._fname), |
e6c42b96 | 156 | include) |
42011059 | 157 | self._add_expr(OrderedDict({'include': incl_fname}), info) |
e6c42b96 | 158 | exprs_include = self._include(include, info, incl_fname, |
16ff40ac | 159 | self._included) |
e6c42b96 MA |
160 | if exprs_include: |
161 | self.exprs.extend(exprs_include.exprs) | |
162 | self.docs.extend(exprs_include.docs) | |
163 | elif "pragma" in expr: | |
164 | self.reject_expr_doc(cur_doc) | |
165 | if len(expr) != 1: | |
166 | raise QAPISemError(info, "invalid 'pragma' directive") | |
167 | pragma = expr['pragma'] | |
168 | if not isinstance(pragma, dict): | |
169 | raise QAPISemError( | |
170 | info, "value of 'pragma' must be an object") | |
171 | for name, value in pragma.items(): | |
172 | self._pragma(name, value, info) | |
173 | else: | |
42011059 JS |
174 | if cur_doc and not cur_doc.symbol: |
175 | raise QAPISemError( | |
176 | cur_doc.info, "definition documentation required") | |
177 | self._add_expr(expr, info, cur_doc) | |
e6c42b96 MA |
178 | cur_doc = None |
179 | self.reject_expr_doc(cur_doc) | |
180 | ||
42011059 JS |
181 | def _add_expr(self, expr: Mapping[str, object], |
182 | info: QAPISourceInfo, | |
183 | doc: Optional['QAPIDoc'] = None) -> None: | |
184 | self.exprs.append(QAPIExpression(expr, info, doc)) | |
185 | ||
e6c42b96 | 186 | @staticmethod |
810aff8f | 187 | def reject_expr_doc(doc: Optional['QAPIDoc']) -> None: |
e6c42b96 MA |
188 | if doc and doc.symbol: |
189 | raise QAPISemError( | |
190 | doc.info, | |
191 | "documentation for '%s' is not followed by the definition" | |
192 | % doc.symbol) | |
193 | ||
43b1be65 | 194 | @staticmethod |
810aff8f JS |
195 | def _include(include: str, |
196 | info: QAPISourceInfo, | |
197 | incl_fname: str, | |
198 | previously_included: Set[str] | |
199 | ) -> Optional['QAPISchemaParser']: | |
e6c42b96 MA |
200 | incl_abs_fname = os.path.abspath(incl_fname) |
201 | # catch inclusion cycle | |
810aff8f | 202 | inf: Optional[QAPISourceInfo] = info |
e6c42b96 MA |
203 | while inf: |
204 | if incl_abs_fname == os.path.abspath(inf.fname): | |
205 | raise QAPISemError(info, "inclusion loop for %s" % include) | |
206 | inf = inf.parent | |
207 | ||
208 | # skip multiple include of the same file | |
209 | if incl_abs_fname in previously_included: | |
210 | return None | |
211 | ||
3404e574 JS |
212 | try: |
213 | return QAPISchemaParser(incl_fname, previously_included, info) | |
214 | except OSError as err: | |
215 | raise QAPISemError( | |
216 | info, | |
217 | f"can't read include file '{incl_fname}': {err.strerror}" | |
218 | ) from err | |
e6c42b96 | 219 | |
43b1be65 | 220 | @staticmethod |
810aff8f | 221 | def _pragma(name: str, value: object, info: QAPISourceInfo) -> None: |
03386200 | 222 | |
810aff8f | 223 | def check_list_str(name: str, value: object) -> List[str]: |
03386200 | 224 | if (not isinstance(value, list) or |
013a3ace | 225 | any(not isinstance(elt, str) for elt in value)): |
03386200 JS |
226 | raise QAPISemError( |
227 | info, | |
228 | "pragma %s must be a list of strings" % name) | |
229 | return value | |
230 | ||
231 | pragma = info.pragma | |
4a67bd31 | 232 | |
e6c42b96 MA |
233 | if name == 'doc-required': |
234 | if not isinstance(value, bool): | |
235 | raise QAPISemError(info, | |
236 | "pragma 'doc-required' must be boolean") | |
03386200 | 237 | pragma.doc_required = value |
05ebf841 | 238 | elif name == 'command-name-exceptions': |
03386200 | 239 | pragma.command_name_exceptions = check_list_str(name, value) |
b86df374 | 240 | elif name == 'command-returns-exceptions': |
03386200 | 241 | pragma.command_returns_exceptions = check_list_str(name, value) |
0cec5011 MA |
242 | elif name == 'documentation-exceptions': |
243 | pragma.documentation_exceptions = check_list_str(name, value) | |
b86df374 | 244 | elif name == 'member-name-exceptions': |
03386200 | 245 | pragma.member_name_exceptions = check_list_str(name, value) |
e6c42b96 MA |
246 | else: |
247 | raise QAPISemError(info, "unknown pragma '%s'" % name) | |
248 | ||
810aff8f | 249 | def accept(self, skip_comment: bool = True) -> None: |
d4092ffa JS |
250 | """ |
251 | Read and store the next token. | |
252 | ||
253 | :param skip_comment: | |
254 | When false, return COMMENT tokens ("#"). | |
255 | This is used when reading documentation blocks. | |
256 | ||
257 | :return: | |
258 | None. Several instance attributes are updated instead: | |
259 | ||
260 | - ``.tok`` represents the token type. See below for values. | |
261 | - ``.info`` describes the token's source location. | |
262 | - ``.val`` is the token's value, if any. See below. | |
263 | - ``.pos`` is the buffer index of the first character of | |
264 | the token. | |
265 | ||
266 | * Single-character tokens: | |
267 | ||
268 | These are "{", "}", ":", ",", "[", and "]". | |
269 | ``.tok`` holds the single character and ``.val`` is None. | |
270 | ||
271 | * Multi-character tokens: | |
272 | ||
273 | * COMMENT: | |
274 | ||
275 | This token is not normally returned by the lexer, but it can | |
276 | be when ``skip_comment`` is False. ``.tok`` is "#", and | |
277 | ``.val`` is a string including all chars until end-of-line, | |
278 | including the "#" itself. | |
279 | ||
280 | * STRING: | |
281 | ||
282 | ``.tok`` is "'", the single quote. ``.val`` contains the | |
283 | string, excluding the surrounding quotes. | |
284 | ||
285 | * TRUE and FALSE: | |
286 | ||
287 | ``.tok`` is either "t" or "f", ``.val`` will be the | |
288 | corresponding bool value. | |
289 | ||
290 | * EOF: | |
291 | ||
292 | ``.tok`` and ``.val`` will both be None at EOF. | |
293 | """ | |
e6c42b96 MA |
294 | while True: |
295 | self.tok = self.src[self.cursor] | |
296 | self.pos = self.cursor | |
297 | self.cursor += 1 | |
298 | self.val = None | |
299 | ||
300 | if self.tok == '#': | |
301 | if self.src[self.cursor] == '#': | |
302 | # Start of doc comment | |
303 | skip_comment = False | |
304 | self.cursor = self.src.find('\n', self.cursor) | |
305 | if not skip_comment: | |
306 | self.val = self.src[self.pos:self.cursor] | |
307 | return | |
308 | elif self.tok in '{}:,[]': | |
309 | return | |
310 | elif self.tok == "'": | |
311 | # Note: we accept only printable ASCII | |
312 | string = '' | |
313 | esc = False | |
314 | while True: | |
315 | ch = self.src[self.cursor] | |
316 | self.cursor += 1 | |
317 | if ch == '\n': | |
318 | raise QAPIParseError(self, "missing terminating \"'\"") | |
319 | if esc: | |
320 | # Note: we recognize only \\ because we have | |
321 | # no use for funny characters in strings | |
322 | if ch != '\\': | |
323 | raise QAPIParseError(self, | |
324 | "unknown escape \\%s" % ch) | |
325 | esc = False | |
326 | elif ch == '\\': | |
327 | esc = True | |
328 | continue | |
329 | elif ch == "'": | |
330 | self.val = string | |
331 | return | |
332 | if ord(ch) < 32 or ord(ch) >= 127: | |
333 | raise QAPIParseError( | |
334 | self, "funny character in string") | |
335 | string += ch | |
336 | elif self.src.startswith('true', self.pos): | |
337 | self.val = True | |
338 | self.cursor += 3 | |
339 | return | |
340 | elif self.src.startswith('false', self.pos): | |
341 | self.val = False | |
342 | self.cursor += 4 | |
343 | return | |
344 | elif self.tok == '\n': | |
345 | if self.cursor == len(self.src): | |
346 | self.tok = None | |
347 | return | |
348 | self.info = self.info.next_line() | |
349 | self.line_pos = self.cursor | |
350 | elif not self.tok.isspace(): | |
351 | # Show up to next structural, whitespace or quote | |
352 | # character | |
5b5fe0e0 | 353 | match = must_match('[^[\\]{}:,\\s\']+', |
e0e8a0ac | 354 | self.src[self.cursor-1:]) |
e6c42b96 MA |
355 | raise QAPIParseError(self, "stray '%s'" % match.group(0)) |
356 | ||
810aff8f JS |
357 | def get_members(self) -> Dict[str, object]: |
358 | expr: Dict[str, object] = OrderedDict() | |
e6c42b96 MA |
359 | if self.tok == '}': |
360 | self.accept() | |
361 | return expr | |
362 | if self.tok != "'": | |
363 | raise QAPIParseError(self, "expected string or '}'") | |
364 | while True: | |
365 | key = self.val | |
234dce2c JS |
366 | assert isinstance(key, str) # Guaranteed by tok == "'" |
367 | ||
e6c42b96 MA |
368 | self.accept() |
369 | if self.tok != ':': | |
370 | raise QAPIParseError(self, "expected ':'") | |
371 | self.accept() | |
372 | if key in expr: | |
373 | raise QAPIParseError(self, "duplicate key '%s'" % key) | |
9cd0205d | 374 | expr[key] = self.get_expr() |
e6c42b96 MA |
375 | if self.tok == '}': |
376 | self.accept() | |
377 | return expr | |
378 | if self.tok != ',': | |
379 | raise QAPIParseError(self, "expected ',' or '}'") | |
380 | self.accept() | |
381 | if self.tok != "'": | |
382 | raise QAPIParseError(self, "expected string") | |
383 | ||
810aff8f JS |
384 | def get_values(self) -> List[object]: |
385 | expr: List[object] = [] | |
e6c42b96 MA |
386 | if self.tok == ']': |
387 | self.accept() | |
388 | return expr | |
c256263f | 389 | if self.tok not in tuple("{['tf"): |
e6c42b96 | 390 | raise QAPIParseError( |
0e92a19b | 391 | self, "expected '{', '[', ']', string, or boolean") |
e6c42b96 | 392 | while True: |
9cd0205d | 393 | expr.append(self.get_expr()) |
e6c42b96 MA |
394 | if self.tok == ']': |
395 | self.accept() | |
396 | return expr | |
397 | if self.tok != ',': | |
398 | raise QAPIParseError(self, "expected ',' or ']'") | |
399 | self.accept() | |
400 | ||
810aff8f JS |
401 | def get_expr(self) -> _ExprValue: |
402 | expr: _ExprValue | |
e6c42b96 MA |
403 | if self.tok == '{': |
404 | self.accept() | |
405 | expr = self.get_members() | |
406 | elif self.tok == '[': | |
407 | self.accept() | |
408 | expr = self.get_values() | |
c256263f JS |
409 | elif self.tok in tuple("'tf"): |
410 | assert isinstance(self.val, (str, bool)) | |
e6c42b96 MA |
411 | expr = self.val |
412 | self.accept() | |
413 | else: | |
414 | raise QAPIParseError( | |
0e92a19b | 415 | self, "expected '{', '[', string, or boolean") |
e6c42b96 MA |
416 | return expr |
417 | ||
3d035cd2 MA |
418 | def get_doc_line(self) -> Optional[str]: |
419 | if self.tok != '#': | |
420 | raise QAPIParseError( | |
421 | self, "documentation comment must end with '##'") | |
422 | assert isinstance(self.val, str) | |
423 | if self.val.startswith('##'): | |
424 | # End of doc comment | |
425 | if self.val != '##': | |
426 | raise QAPIParseError( | |
427 | self, "junk after '##' at end of documentation comment") | |
428 | return None | |
429 | if self.val == '#': | |
430 | return '' | |
431 | if self.val[1] != ' ': | |
432 | raise QAPIParseError(self, "missing space after #") | |
433 | return self.val[2:].rstrip() | |
434 | ||
435 | @staticmethod | |
436 | def _match_at_name_colon(string: str) -> Optional[Match[str]]: | |
437 | return re.match(r'@([^:]*): *', string) | |
438 | ||
439 | def get_doc_indented(self, doc: 'QAPIDoc') -> Optional[str]: | |
440 | self.accept(False) | |
441 | line = self.get_doc_line() | |
442 | while line == '': | |
443 | doc.append_line(line) | |
444 | self.accept(False) | |
445 | line = self.get_doc_line() | |
446 | if line is None: | |
447 | return line | |
448 | indent = must_match(r'\s*', line).end() | |
449 | if not indent: | |
450 | return line | |
451 | doc.append_line(line[indent:]) | |
452 | prev_line_blank = False | |
453 | while True: | |
454 | self.accept(False) | |
455 | line = self.get_doc_line() | |
456 | if line is None: | |
457 | return line | |
458 | if self._match_at_name_colon(line): | |
459 | return line | |
460 | cur_indent = must_match(r'\s*', line).end() | |
461 | if line != '' and cur_indent < indent: | |
462 | if prev_line_blank: | |
463 | return line | |
464 | raise QAPIParseError( | |
465 | self, | |
466 | "unexpected de-indent (expected at least %d spaces)" % | |
467 | indent) | |
468 | doc.append_line(line[indent:]) | |
469 | prev_line_blank = True | |
470 | ||
471 | def get_doc_paragraph(self, doc: 'QAPIDoc') -> Optional[str]: | |
472 | while True: | |
473 | self.accept(False) | |
474 | line = self.get_doc_line() | |
475 | if line is None: | |
476 | return line | |
477 | if line == '': | |
478 | return line | |
479 | doc.append_line(line) | |
480 | ||
481 | def get_doc(self) -> 'QAPIDoc': | |
e6c42b96 MA |
482 | if self.val != '##': |
483 | raise QAPIParseError( | |
484 | self, "junk after '##' at start of documentation comment") | |
3d035cd2 | 485 | info = self.info |
e6c42b96 | 486 | self.accept(False) |
3d035cd2 MA |
487 | line = self.get_doc_line() |
488 | if line is not None and line.startswith('@'): | |
489 | # Definition documentation | |
490 | if not line.endswith(':'): | |
491 | raise QAPIParseError(self, "line should end with ':'") | |
492 | # Invalid names are not checked here, but the name | |
493 | # provided *must* match the following definition, | |
494 | # which *is* validated in expr.py. | |
495 | symbol = line[1:-1] | |
496 | if not symbol: | |
497 | raise QAPIParseError(self, "name required after '@'") | |
adb0193b | 498 | doc = QAPIDoc(info, symbol) |
3d035cd2 MA |
499 | self.accept(False) |
500 | line = self.get_doc_line() | |
501 | no_more_args = False | |
502 | ||
503 | while line is not None: | |
504 | # Blank lines | |
505 | while line == '': | |
506 | self.accept(False) | |
507 | line = self.get_doc_line() | |
508 | if line is None: | |
509 | break | |
510 | # Non-blank line, first of a section | |
629c5075 MA |
511 | if line == 'Features:': |
512 | if doc.features: | |
513 | raise QAPIParseError( | |
514 | self, "duplicated 'Features:' line") | |
3d035cd2 MA |
515 | self.accept(False) |
516 | line = self.get_doc_line() | |
517 | while line == '': | |
518 | self.accept(False) | |
519 | line = self.get_doc_line() | |
520 | while (line is not None | |
521 | and (match := self._match_at_name_colon(line))): | |
adb0193b | 522 | doc.new_feature(self.info, match.group(1)) |
3d035cd2 MA |
523 | text = line[match.end():] |
524 | if text: | |
525 | doc.append_line(text) | |
526 | line = self.get_doc_indented(doc) | |
629c5075 MA |
527 | if not doc.features: |
528 | raise QAPIParseError( | |
529 | self, 'feature descriptions expected') | |
3d035cd2 MA |
530 | no_more_args = True |
531 | elif match := self._match_at_name_colon(line): | |
532 | # description | |
533 | if no_more_args: | |
534 | raise QAPIParseError( | |
535 | self, | |
536 | "description of '@%s:' follows a section" | |
537 | % match.group(1)) | |
538 | while (line is not None | |
539 | and (match := self._match_at_name_colon(line))): | |
adb0193b | 540 | doc.new_argument(self.info, match.group(1)) |
3d035cd2 MA |
541 | text = line[match.end():] |
542 | if text: | |
543 | doc.append_line(text) | |
544 | line = self.get_doc_indented(doc) | |
545 | no_more_args = True | |
546 | elif match := re.match( | |
3a025d3d | 547 | r'(Returns|Errors|Since|Notes?|Examples?|TODO): *', |
3d035cd2 MA |
548 | line): |
549 | # tagged section | |
adb0193b | 550 | doc.new_tagged_section(self.info, match.group(1)) |
3d035cd2 MA |
551 | text = line[match.end():] |
552 | if text: | |
553 | doc.append_line(text) | |
554 | line = self.get_doc_indented(doc) | |
555 | no_more_args = True | |
556 | elif line.startswith('='): | |
d98884b7 MA |
557 | raise QAPIParseError( |
558 | self, | |
559 | "unexpected '=' markup in definition documentation") | |
3d035cd2 MA |
560 | else: |
561 | # tag-less paragraph | |
adb0193b | 562 | doc.ensure_untagged_section(self.info) |
3d035cd2 MA |
563 | doc.append_line(line) |
564 | line = self.get_doc_paragraph(doc) | |
565 | else: | |
566 | # Free-form documentation | |
adb0193b MA |
567 | doc = QAPIDoc(info) |
568 | doc.ensure_untagged_section(self.info) | |
3d035cd2 MA |
569 | first = True |
570 | while line is not None: | |
571 | if match := self._match_at_name_colon(line): | |
56c64dd6 MA |
572 | raise QAPIParseError( |
573 | self, | |
3d035cd2 MA |
574 | "'@%s:' not allowed in free-form documentation" |
575 | % match.group(1)) | |
576 | if line.startswith('='): | |
577 | if not first: | |
578 | raise QAPIParseError( | |
579 | self, | |
580 | "'=' heading must come first in a comment block") | |
581 | doc.append_line(line) | |
582 | self.accept(False) | |
583 | line = self.get_doc_line() | |
584 | first = False | |
e6c42b96 | 585 | |
3d035cd2 MA |
586 | self.accept(False) |
587 | doc.end() | |
588 | return doc | |
e6c42b96 MA |
589 | |
590 | ||
baa310f1 | 591 | class QAPIDoc: |
e6c42b96 MA |
592 | """ |
593 | A documentation comment block, either definition or free-form | |
594 | ||
595 | Definition documentation blocks consist of | |
596 | ||
597 | * a body section: one line naming the definition, followed by an | |
598 | overview (any number of lines) | |
599 | ||
600 | * argument sections: a description of each argument (for commands | |
601 | and events) or member (for structs, unions and alternates) | |
602 | ||
603 | * features sections: a description of each feature flag | |
604 | ||
605 | * additional (non-argument) sections, possibly tagged | |
606 | ||
607 | Free-form documentation blocks consist only of a body section. | |
608 | """ | |
609 | ||
baa310f1 | 610 | class Section: |
2daf52df | 611 | # pylint: disable=too-few-public-methods |
adb0193b | 612 | def __init__(self, info: QAPISourceInfo, |
31c54b92 | 613 | tag: Optional[str] = None): |
15333abe | 614 | # section source info, i.e. where it begins |
adb0193b | 615 | self.info = info |
573e2223 | 616 | # section tag, if any ('Returns', '@name', ...) |
31c54b92 | 617 | self.tag = tag |
573e2223 | 618 | # section text without tag |
e6c42b96 | 619 | self.text = '' |
a69a6d4b | 620 | |
3d035cd2 | 621 | def append_line(self, line: str) -> None: |
08349786 | 622 | self.text += line + '\n' |
e6c42b96 MA |
623 | |
624 | class ArgSection(Section): | |
adb0193b MA |
625 | def __init__(self, info: QAPISourceInfo, tag: str): |
626 | super().__init__(info, tag) | |
e7ac60fc | 627 | self.member: Optional['QAPISchemaMember'] = None |
e6c42b96 | 628 | |
e7ac60fc | 629 | def connect(self, member: 'QAPISchemaMember') -> None: |
e6c42b96 MA |
630 | self.member = member |
631 | ||
adb0193b | 632 | def __init__(self, info: QAPISourceInfo, symbol: Optional[str] = None): |
3d035cd2 | 633 | # info points to the doc comment block's first line |
e6c42b96 | 634 | self.info = info |
3d035cd2 MA |
635 | # definition doc's symbol, None for free-form doc |
636 | self.symbol: Optional[str] = symbol | |
637 | # the sections in textual order | |
adb0193b | 638 | self.all_sections: List[QAPIDoc.Section] = [QAPIDoc.Section(info)] |
3d035cd2 MA |
639 | # the body section |
640 | self.body: Optional[QAPIDoc.Section] = self.all_sections[0] | |
641 | # dicts mapping parameter/feature names to their description | |
642 | self.args: Dict[str, QAPIDoc.ArgSection] = {} | |
643 | self.features: Dict[str, QAPIDoc.ArgSection] = {} | |
3a025d3d | 644 | # a command's "Returns" and "Errors" section |
ba7f63f9 | 645 | self.returns: Optional[QAPIDoc.Section] = None |
3a025d3d | 646 | self.errors: Optional[QAPIDoc.Section] = None |
ba7f63f9 MA |
647 | # "Since" section |
648 | self.since: Optional[QAPIDoc.Section] = None | |
3d035cd2 | 649 | # sections other than .body, .args, .features |
5f0d9f3b | 650 | self.sections: List[QAPIDoc.Section] = [] |
3e32dca3 | 651 | |
3d035cd2 MA |
652 | def end(self) -> None: |
653 | for section in self.all_sections: | |
654 | section.text = section.text.strip('\n') | |
655 | if section.tag is not None and section.text == '': | |
656 | raise QAPISemError( | |
657 | section.info, "text required after '%s:'" % section.tag) | |
e6c42b96 | 658 | |
adb0193b | 659 | def ensure_untagged_section(self, info: QAPISourceInfo) -> None: |
3d035cd2 MA |
660 | if self.all_sections and not self.all_sections[-1].tag: |
661 | # extend current section | |
662 | self.all_sections[-1].text += '\n' | |
e6c42b96 | 663 | return |
3d035cd2 | 664 | # start new section |
adb0193b | 665 | section = self.Section(info) |
3d035cd2 MA |
666 | self.sections.append(section) |
667 | self.all_sections.append(section) | |
668 | ||
adb0193b | 669 | def new_tagged_section(self, info: QAPISourceInfo, tag: str) -> None: |
adb0193b | 670 | section = self.Section(info, tag) |
ba7f63f9 MA |
671 | if tag == 'Returns': |
672 | if self.returns: | |
673 | raise QAPISemError( | |
674 | info, "duplicated '%s' section" % tag) | |
675 | self.returns = section | |
3a025d3d MA |
676 | elif tag == 'Errors': |
677 | if self.errors: | |
678 | raise QAPISemError( | |
679 | info, "duplicated '%s' section" % tag) | |
680 | self.errors = section | |
ba7f63f9 MA |
681 | elif tag == 'Since': |
682 | if self.since: | |
683 | raise QAPISemError( | |
684 | info, "duplicated '%s' section" % tag) | |
685 | self.since = section | |
3d035cd2 MA |
686 | self.sections.append(section) |
687 | self.all_sections.append(section) | |
e6c42b96 | 688 | |
adb0193b | 689 | def _new_description(self, info: QAPISourceInfo, name: str, |
3d035cd2 | 690 | desc: Dict[str, ArgSection]) -> None: |
e6c42b96 | 691 | if not name: |
adb0193b | 692 | raise QAPISemError(info, "invalid parameter name") |
3d035cd2 | 693 | if name in desc: |
adb0193b MA |
694 | raise QAPISemError(info, "'%s' parameter name duplicated" % name) |
695 | section = self.ArgSection(info, '@' + name) | |
3d035cd2 MA |
696 | self.all_sections.append(section) |
697 | desc[name] = section | |
e6c42b96 | 698 | |
adb0193b MA |
699 | def new_argument(self, info: QAPISourceInfo, name: str) -> None: |
700 | self._new_description(info, name, self.args) | |
1e20a775 | 701 | |
adb0193b MA |
702 | def new_feature(self, info: QAPISourceInfo, name: str) -> None: |
703 | self._new_description(info, name, self.features) | |
e6c42b96 | 704 | |
3d035cd2 MA |
705 | def append_line(self, line: str) -> None: |
706 | self.all_sections[-1].append_line(line) | |
e6c42b96 | 707 | |
e7ac60fc | 708 | def connect_member(self, member: 'QAPISchemaMember') -> None: |
e6c42b96 | 709 | if member.name not in self.args: |
d5e2f3d0 | 710 | assert member.info |
0cec5011 MA |
711 | if self.symbol not in member.info.pragma.documentation_exceptions: |
712 | raise QAPISemError(member.info, | |
713 | "%s '%s' lacks documentation" | |
714 | % (member.role, member.name)) | |
3d035cd2 | 715 | self.args[member.name] = QAPIDoc.ArgSection( |
adb0193b | 716 | self.info, '@' + member.name) |
e6c42b96 MA |
717 | self.args[member.name].connect(member) |
718 | ||
e7ac60fc | 719 | def connect_feature(self, feature: 'QAPISchemaFeature') -> None: |
e151941d MA |
720 | if feature.name not in self.features: |
721 | raise QAPISemError(feature.info, | |
722 | "feature '%s' lacks documentation" | |
723 | % feature.name) | |
e151941d MA |
724 | self.features[feature.name].connect(feature) |
725 | ||
42011059 | 726 | def check_expr(self, expr: QAPIExpression) -> None: |
e1f684ea MA |
727 | if 'command' in expr: |
728 | if self.returns and 'returns' not in expr: | |
729 | raise QAPISemError( | |
730 | self.returns.info, | |
731 | "'Returns' section, but command doesn't return anything") | |
732 | else: | |
3a025d3d MA |
733 | if self.returns: |
734 | raise QAPISemError( | |
735 | self.returns.info, | |
736 | "'Returns' section is only valid for commands") | |
737 | if self.errors: | |
738 | raise QAPISemError( | |
4f8f199f | 739 | self.errors.info, |
3a025d3d | 740 | "'Errors' section is only valid for commands") |
e6c42b96 | 741 | |
5f0d9f3b | 742 | def check(self) -> None: |
e151941d | 743 | |
5f0d9f3b JS |
744 | def check_args_section( |
745 | args: Dict[str, QAPIDoc.ArgSection], what: str | |
746 | ) -> None: | |
e151941d MA |
747 | bogus = [name for name, section in args.items() |
748 | if not section.member] | |
749 | if bogus: | |
750 | raise QAPISemError( | |
15333abe | 751 | args[bogus[0]].info, |
012336a1 JS |
752 | "documented %s%s '%s' %s not exist" % ( |
753 | what, | |
754 | "s" if len(bogus) > 1 else "", | |
755 | "', '".join(bogus), | |
756 | "do" if len(bogus) > 1 else "does" | |
757 | )) | |
758 | ||
759 | check_args_section(self.args, 'member') | |
760 | check_args_section(self.features, 'feature') |