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