qapi: remove "Example" doc section
[qemu/armbru.git] / scripts / qapi / parser.py
blobadc85b5b3946525d42dbb7a34aee4c8b4b1d423b
1 # -*- coding: utf-8 -*-
3 # QAPI schema parser
5 # Copyright IBM, Corp. 2011
6 # Copyright (c) 2013-2019 Red Hat Inc.
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>
14 # This work is licensed under the terms of the GNU GPL, version 2.
15 # See the COPYING file in the top-level directory.
17 from collections import OrderedDict
18 import os
19 import re
20 from typing import (
21 TYPE_CHECKING,
22 Any,
23 Dict,
24 List,
25 Mapping,
26 Match,
27 Optional,
28 Set,
29 Union,
32 from .common import must_match
33 from .error import QAPISemError, QAPISourceError
34 from .source import QAPISourceInfo
37 if TYPE_CHECKING:
38 # pylint: disable=cyclic-import
39 # TODO: Remove cycle. [schema -> expr -> parser -> schema]
40 from .schema import QAPISchemaFeature, QAPISchemaMember
43 # Return value alias for get_expr().
44 _ExprValue = Union[List[object], Dict[str, object], str, bool]
47 class QAPIExpression(Dict[str, Any]):
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
58 class QAPIParseError(QAPISourceError):
59 """Error class for all QAPI schema parsing errors."""
60 def __init__(self, parser: 'QAPISchemaParser', msg: str):
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)
70 class QAPISchemaParser:
71 """
72 Parse QAPI schema source.
74 Parse a JSON-esque schema file and process directives. See
75 qapi-code-gen.rst section "Schema Syntax" for the exact syntax.
76 Grammatical validation is handled later by `expr.check_exprs()`.
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.
86 :ivar exprs: Resulting parsed expressions.
87 :ivar docs: Resulting parsed documentation blocks.
89 :raise OSError: For problems reading the root schema document.
90 :raise QAPIError: For errors in the schema source.
91 """
92 def __init__(self,
93 fname: str,
94 previously_included: Optional[Set[str]] = None,
95 incl_info: Optional[QAPISourceInfo] = None):
96 self._fname = fname
97 self._included = previously_included or set()
98 self._included.add(os.path.abspath(self._fname))
99 self.src = ''
101 # Lexer state (see `accept` for details):
102 self.info = QAPISourceInfo(self._fname, incl_info)
103 self.tok: Union[None, str] = None
104 self.pos = 0
105 self.cursor = 0
106 self.val: Optional[Union[bool, str]] = None
107 self.line_pos = 0
109 # Parser output:
110 self.exprs: List[QAPIExpression] = []
111 self.docs: List[QAPIDoc] = []
113 # Showtime!
114 self._parse()
116 def _parse(self) -> None:
118 Parse the QAPI schema document.
120 :return: None. Results are stored in ``.exprs`` and ``.docs``.
122 cur_doc = None
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'
130 # Prime the lexer:
131 self.accept()
133 # Parse until done:
134 while self.tok is not None:
135 info = self.info
136 if self.tok == '#':
137 self.reject_expr_doc(cur_doc)
138 cur_doc = self.get_doc()
139 self.docs.append(cur_doc)
140 continue
142 expr = self.get_expr()
143 if not isinstance(expr, dict):
144 raise QAPISemError(
145 info, "top-level expression must be an object")
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")
155 incl_fname = os.path.join(os.path.dirname(self._fname),
156 include)
157 self._add_expr(OrderedDict({'include': incl_fname}), info)
158 exprs_include = self._include(include, info, incl_fname,
159 self._included)
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:
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)
178 cur_doc = None
179 self.reject_expr_doc(cur_doc)
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))
186 @staticmethod
187 def reject_expr_doc(doc: Optional['QAPIDoc']) -> None:
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)
194 @staticmethod
195 def _include(include: str,
196 info: QAPISourceInfo,
197 incl_fname: str,
198 previously_included: Set[str]
199 ) -> Optional['QAPISchemaParser']:
200 incl_abs_fname = os.path.abspath(incl_fname)
201 # catch inclusion cycle
202 inf: Optional[QAPISourceInfo] = info
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
208 # skip multiple include of the same file
209 if incl_abs_fname in previously_included:
210 return None
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
220 @staticmethod
221 def _pragma(name: str, value: object, info: QAPISourceInfo) -> None:
223 def check_list_str(name: str, value: object) -> List[str]:
224 if (not isinstance(value, list) or
225 any(not isinstance(elt, str) for elt in value)):
226 raise QAPISemError(
227 info,
228 "pragma %s must be a list of strings" % name)
229 return value
231 pragma = info.pragma
233 if name == 'doc-required':
234 if not isinstance(value, bool):
235 raise QAPISemError(info,
236 "pragma 'doc-required' must be boolean")
237 pragma.doc_required = value
238 elif name == 'command-name-exceptions':
239 pragma.command_name_exceptions = check_list_str(name, value)
240 elif name == 'command-returns-exceptions':
241 pragma.command_returns_exceptions = check_list_str(name, value)
242 elif name == 'documentation-exceptions':
243 pragma.documentation_exceptions = check_list_str(name, value)
244 elif name == 'member-name-exceptions':
245 pragma.member_name_exceptions = check_list_str(name, value)
246 else:
247 raise QAPISemError(info, "unknown pragma '%s'" % name)
249 def accept(self, skip_comment: bool = True) -> None:
251 Read and store the next token.
253 :param skip_comment:
254 When false, return COMMENT tokens ("#").
255 This is used when reading documentation blocks.
257 :return:
258 None. Several instance attributes are updated instead:
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.
266 * Single-character tokens:
268 These are "{", "}", ":", ",", "[", and "]".
269 ``.tok`` holds the single character and ``.val`` is None.
271 * Multi-character tokens:
273 * COMMENT:
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.
280 * STRING:
282 ``.tok`` is "'", the single quote. ``.val`` contains the
283 string, excluding the surrounding quotes.
285 * TRUE and FALSE:
287 ``.tok`` is either "t" or "f", ``.val`` will be the
288 corresponding bool value.
290 * EOF:
292 ``.tok`` and ``.val`` will both be None at EOF.
294 while True:
295 self.tok = self.src[self.cursor]
296 self.pos = self.cursor
297 self.cursor += 1
298 self.val = None
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
353 match = must_match('[^[\\]{}:,\\s\']+',
354 self.src[self.cursor-1:])
355 raise QAPIParseError(self, "stray '%s'" % match.group(0))
357 def get_members(self) -> Dict[str, object]:
358 expr: Dict[str, object] = OrderedDict()
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
366 assert isinstance(key, str) # Guaranteed by tok == "'"
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)
374 expr[key] = self.get_expr()
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")
384 def get_values(self) -> List[object]:
385 expr: List[object] = []
386 if self.tok == ']':
387 self.accept()
388 return expr
389 if self.tok not in tuple("{['tf"):
390 raise QAPIParseError(
391 self, "expected '{', '[', ']', string, or boolean")
392 while True:
393 expr.append(self.get_expr())
394 if self.tok == ']':
395 self.accept()
396 return expr
397 if self.tok != ',':
398 raise QAPIParseError(self, "expected ',' or ']'")
399 self.accept()
401 def get_expr(self) -> _ExprValue:
402 expr: _ExprValue
403 if self.tok == '{':
404 self.accept()
405 expr = self.get_members()
406 elif self.tok == '[':
407 self.accept()
408 expr = self.get_values()
409 elif self.tok in tuple("'tf"):
410 assert isinstance(self.val, (str, bool))
411 expr = self.val
412 self.accept()
413 else:
414 raise QAPIParseError(
415 self, "expected '{', '[', string, or boolean")
416 return expr
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()
435 @staticmethod
436 def _match_at_name_colon(string: str) -> Optional[Match[str]]:
437 return re.match(r'@([^:]*): *', string)
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)
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)
469 prev_line_blank = True
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)
481 def get_doc(self) -> 'QAPIDoc':
482 if self.val != '##':
483 raise QAPIParseError(
484 self, "junk after '##' at start of documentation comment")
485 info = self.info
486 self.accept(False)
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 '@'")
498 doc = QAPIDoc(info, symbol)
499 self.accept(False)
500 line = self.get_doc_line()
501 no_more_args = False
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
511 if line == 'Features:':
512 if doc.features:
513 raise QAPIParseError(
514 self, "duplicated 'Features:' line")
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))):
522 doc.new_feature(self.info, match.group(1))
523 text = line[match.end():]
524 if text:
525 doc.append_line(text)
526 line = self.get_doc_indented(doc)
527 if not doc.features:
528 raise QAPIParseError(
529 self, 'feature descriptions expected')
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))):
540 doc.new_argument(self.info, match.group(1))
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(
547 r'(Returns|Errors|Since|Notes?|Examples?|TODO)'
548 r'(?!::): *',
549 line,
551 # tagged section
553 # Note: "sections" with two colons are left alone as
554 # rST markup and not interpreted as a section heading.
556 # TODO: Remove these errors sometime in 2025 or so
557 # after we've fully transitioned to the new qapidoc
558 # generator.
560 # See commit message for more markup suggestions O:-)
561 if 'Note' in match.group(1):
562 emsg = (
563 f"The '{match.group(1)}' section is no longer "
564 "supported. Please use rST's '.. note::' or "
565 "'.. admonition:: notes' directives, or another "
566 "suitable admonition instead."
568 raise QAPIParseError(self, emsg)
570 if 'Example' in match.group(1):
571 emsg = (
572 f"The '{match.group(1)}' section is no longer "
573 "supported. Please use the '.. qmp-example::' "
574 "directive, or other suitable markup instead."
576 raise QAPIParseError(self, emsg)
578 doc.new_tagged_section(self.info, match.group(1))
579 text = line[match.end():]
580 if text:
581 doc.append_line(text)
582 line = self.get_doc_indented(doc)
583 no_more_args = True
584 elif line.startswith('='):
585 raise QAPIParseError(
586 self,
587 "unexpected '=' markup in definition documentation")
588 else:
589 # tag-less paragraph
590 doc.ensure_untagged_section(self.info)
591 doc.append_line(line)
592 line = self.get_doc_paragraph(doc)
593 else:
594 # Free-form documentation
595 doc = QAPIDoc(info)
596 doc.ensure_untagged_section(self.info)
597 first = True
598 while line is not None:
599 if match := self._match_at_name_colon(line):
600 raise QAPIParseError(
601 self,
602 "'@%s:' not allowed in free-form documentation"
603 % match.group(1))
604 if line.startswith('='):
605 if not first:
606 raise QAPIParseError(
607 self,
608 "'=' heading must come first in a comment block")
609 doc.append_line(line)
610 self.accept(False)
611 line = self.get_doc_line()
612 first = False
614 self.accept()
615 doc.end()
616 return doc
619 class QAPIDoc:
621 A documentation comment block, either definition or free-form
623 Definition documentation blocks consist of
625 * a body section: one line naming the definition, followed by an
626 overview (any number of lines)
628 * argument sections: a description of each argument (for commands
629 and events) or member (for structs, unions and alternates)
631 * features sections: a description of each feature flag
633 * additional (non-argument) sections, possibly tagged
635 Free-form documentation blocks consist only of a body section.
638 class Section:
639 # pylint: disable=too-few-public-methods
640 def __init__(self, info: QAPISourceInfo,
641 tag: Optional[str] = None):
642 # section source info, i.e. where it begins
643 self.info = info
644 # section tag, if any ('Returns', '@name', ...)
645 self.tag = tag
646 # section text without tag
647 self.text = ''
649 def append_line(self, line: str) -> None:
650 self.text += line + '\n'
652 class ArgSection(Section):
653 def __init__(self, info: QAPISourceInfo, tag: str):
654 super().__init__(info, tag)
655 self.member: Optional['QAPISchemaMember'] = None
657 def connect(self, member: 'QAPISchemaMember') -> None:
658 self.member = member
660 def __init__(self, info: QAPISourceInfo, symbol: Optional[str] = None):
661 # info points to the doc comment block's first line
662 self.info = info
663 # definition doc's symbol, None for free-form doc
664 self.symbol: Optional[str] = symbol
665 # the sections in textual order
666 self.all_sections: List[QAPIDoc.Section] = [QAPIDoc.Section(info)]
667 # the body section
668 self.body: Optional[QAPIDoc.Section] = self.all_sections[0]
669 # dicts mapping parameter/feature names to their description
670 self.args: Dict[str, QAPIDoc.ArgSection] = {}
671 self.features: Dict[str, QAPIDoc.ArgSection] = {}
672 # a command's "Returns" and "Errors" section
673 self.returns: Optional[QAPIDoc.Section] = None
674 self.errors: Optional[QAPIDoc.Section] = None
675 # "Since" section
676 self.since: Optional[QAPIDoc.Section] = None
677 # sections other than .body, .args, .features
678 self.sections: List[QAPIDoc.Section] = []
680 def end(self) -> None:
681 for section in self.all_sections:
682 section.text = section.text.strip('\n')
683 if section.tag is not None and section.text == '':
684 raise QAPISemError(
685 section.info, "text required after '%s:'" % section.tag)
687 def ensure_untagged_section(self, info: QAPISourceInfo) -> None:
688 if self.all_sections and not self.all_sections[-1].tag:
689 # extend current section
690 self.all_sections[-1].text += '\n'
691 return
692 # start new section
693 section = self.Section(info)
694 self.sections.append(section)
695 self.all_sections.append(section)
697 def new_tagged_section(self, info: QAPISourceInfo, tag: str) -> None:
698 section = self.Section(info, tag)
699 if tag == 'Returns':
700 if self.returns:
701 raise QAPISemError(
702 info, "duplicated '%s' section" % tag)
703 self.returns = section
704 elif tag == 'Errors':
705 if self.errors:
706 raise QAPISemError(
707 info, "duplicated '%s' section" % tag)
708 self.errors = section
709 elif tag == 'Since':
710 if self.since:
711 raise QAPISemError(
712 info, "duplicated '%s' section" % tag)
713 self.since = section
714 self.sections.append(section)
715 self.all_sections.append(section)
717 def _new_description(self, info: QAPISourceInfo, name: str,
718 desc: Dict[str, ArgSection]) -> None:
719 if not name:
720 raise QAPISemError(info, "invalid parameter name")
721 if name in desc:
722 raise QAPISemError(info, "'%s' parameter name duplicated" % name)
723 section = self.ArgSection(info, '@' + name)
724 self.all_sections.append(section)
725 desc[name] = section
727 def new_argument(self, info: QAPISourceInfo, name: str) -> None:
728 self._new_description(info, name, self.args)
730 def new_feature(self, info: QAPISourceInfo, name: str) -> None:
731 self._new_description(info, name, self.features)
733 def append_line(self, line: str) -> None:
734 self.all_sections[-1].append_line(line)
736 def connect_member(self, member: 'QAPISchemaMember') -> None:
737 if member.name not in self.args:
738 assert member.info
739 if self.symbol not in member.info.pragma.documentation_exceptions:
740 raise QAPISemError(member.info,
741 "%s '%s' lacks documentation"
742 % (member.role, member.name))
743 self.args[member.name] = QAPIDoc.ArgSection(
744 self.info, '@' + member.name)
745 self.args[member.name].connect(member)
747 def connect_feature(self, feature: 'QAPISchemaFeature') -> None:
748 if feature.name not in self.features:
749 raise QAPISemError(feature.info,
750 "feature '%s' lacks documentation"
751 % feature.name)
752 self.features[feature.name].connect(feature)
754 def check_expr(self, expr: QAPIExpression) -> None:
755 if 'command' in expr:
756 if self.returns and 'returns' not in expr:
757 raise QAPISemError(
758 self.returns.info,
759 "'Returns' section, but command doesn't return anything")
760 else:
761 if self.returns:
762 raise QAPISemError(
763 self.returns.info,
764 "'Returns' section is only valid for commands")
765 if self.errors:
766 raise QAPISemError(
767 self.errors.info,
768 "'Errors' section is only valid for commands")
770 def check(self) -> None:
772 def check_args_section(
773 args: Dict[str, QAPIDoc.ArgSection], what: str
774 ) -> None:
775 bogus = [name for name, section in args.items()
776 if not section.member]
777 if bogus:
778 raise QAPISemError(
779 args[bogus[0]].info,
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"
787 check_args_section(self.args, 'member')
788 check_args_section(self.features, 'feature')