diff --git a/changelogs/fragments/108-markup.yml b/changelogs/fragments/108-markup.yml new file mode 100644 index 00000000..43e65174 --- /dev/null +++ b/changelogs/fragments/108-markup.yml @@ -0,0 +1,2 @@ +minor_change: + - Internal refactoring of markup code (https://github.com/ansible-community/antsibull-docs/pull/108). diff --git a/src/antsibull_docs/jinja2/environment.py b/src/antsibull_docs/jinja2/environment.py index e4668c9f..f255c288 100644 --- a/src/antsibull_docs/jinja2/environment.py +++ b/src/antsibull_docs/jinja2/environment.py @@ -10,6 +10,7 @@ from jinja2 import BaseLoader, Environment, FileSystemLoader, PackageLoader +from ..markup.rstify import rst_code, rst_escape from ..utils.collection_name_transformer import CollectionNameTransformer from .filters import ( do_max, @@ -22,9 +23,9 @@ rst_xline, to_ini_value, to_json, + html_ify, + rst_ify, ) -from .htmlify import html_ify -from .rstify import rst_code, rst_escape, rst_ify from .tests import still_relevant, test_list # kludge_ns gives us a kludgey way to set variables inside of loops that need to be visible outside diff --git a/src/antsibull_docs/jinja2/filters.py b/src/antsibull_docs/jinja2/filters.py index ee20ca51..e83b7224 100644 --- a/src/antsibull_docs/jinja2/filters.py +++ b/src/antsibull_docs/jinja2/filters.py @@ -13,6 +13,10 @@ from antsibull_core.logging import log from jinja2.runtime import Context, Undefined +from jinja2.utils import pass_context + +from ..markup.rstify import rst_ify as rst_ify_impl +from ..markup.htmlify import html_ify as html_ify_impl mlog = log.fields(mod=__name__) @@ -127,3 +131,41 @@ def to_ini_value(data: t.Any) -> str: return 'MAPPINGS ARE NOT SUPPORTED' # Handle other values (booleans, integers, floats) as JSON return json.dumps(data) + + +@pass_context +def rst_ify(context: Context, text: str, + *, + plugin_fqcn: t.Optional[str] = None, + plugin_type: t.Optional[str] = None) -> str: + ''' convert symbols like I(this is in italics) to valid restructured text ''' + flog = mlog.fields(func='rst_ify') + flog.fields(text=text).debug('Enter') + + plugin_fqcn, plugin_type = extract_plugin_data( + context, plugin_fqcn=plugin_fqcn, plugin_type=plugin_type) + + text, counts = rst_ify_impl(text, plugin_fqcn=plugin_fqcn, plugin_type=plugin_type) + + flog.fields(counts=counts).info('Number of macros converted to rst equivalents') + flog.debug('Leave') + return text + + +@pass_context +def html_ify(context: Context, text: str, + *, + plugin_fqcn: t.Optional[str] = None, + plugin_type: t.Optional[str] = None) -> str: + ''' convert symbols like I(this is in italics) to valid HTML ''' + flog = mlog.fields(func='html_ify') + flog.fields(text=text).debug('Enter') + + plugin_fqcn, plugin_type = extract_plugin_data( + context, plugin_fqcn=plugin_fqcn, plugin_type=plugin_type) + + text, counts = html_ify_impl(text, plugin_fqcn=plugin_fqcn, plugin_type=plugin_type) + + flog.fields(counts=counts).info('Number of macros converted to html equivalents') + flog.debug('Leave') + return text diff --git a/src/antsibull_docs/jinja2/htmlify.py b/src/antsibull_docs/jinja2/htmlify.py deleted file mode 100644 index 91c06df7..00000000 --- a/src/antsibull_docs/jinja2/htmlify.py +++ /dev/null @@ -1,325 +0,0 @@ -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or -# https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: 2022, Ansible Project -""" -htmlify Jinja2 filter for use in Ansible documentation. -""" - -import re -import typing as t -from html import escape as html_escape -from urllib.parse import quote - -from antsibull_core.logging import log -from jinja2.runtime import Context -from jinja2.utils import pass_context - -from ..semantic_helper import parse_option, parse_return_value -from .filters import extract_plugin_data -from .parser import Command, CommandSet, convert_text - -mlog = log.fields(mod=__name__) - -_MODULE = re.compile(r"^([^).]+)\.([^).]+)\.([^)]+)$") -_PLUGIN = re.compile(r"^([^).]+)\.([^).]+)\.([^)]+)#([a-z]+)$") - - -def _escape_url(url: str) -> str: - # We include '<>[]{}' in safe to allow urls such as 'https://:[PORT]/v{version}/' to - # remain unmangled by percent encoding - return quote(url, safe=':/#?%<>[]{}') - - -def _create_error(text: str, error: str) -> str: - text = f'{html_escape(text)}' - return f'ERROR while parsing {text}: {html_escape(error)}' - - -class _Context: - j2_context: Context - counts: t.Dict[str, int] - plugin_fqcn: t.Optional[str] - plugin_type: t.Optional[str] - - def __init__(self, j2_context: Context, - plugin_fqcn: t.Optional[str] = None, - plugin_type: t.Optional[str] = None): - self.j2_context = j2_context - self.counts = { - 'italic': 0, - 'bold': 0, - 'module': 0, - 'plugin': 0, - 'link': 0, - 'url': 0, - 'ref': 0, - 'const': 0, - 'option-name': 0, - 'option-value': 0, - 'environment-var': 0, - 'return-value': 0, - 'ruler': 0, - } - self.plugin_fqcn, self.plugin_type = extract_plugin_data( - j2_context, plugin_fqcn=plugin_fqcn, plugin_type=plugin_type) - - -# In the following, we make heavy use of escaped whitespace ("\ ") being removed from the output. -# See -# https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#character-level-inline-markup-1 -# for further information. - - -class _Italic(Command): - command = 'I' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['italic'] += 1 - return f"{html_escape(parameters[0])}" - - -class _Bold(Command): - command = 'B' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['bold'] += 1 - return f"{html_escape(parameters[0])}" - - -class _Module(Command): - command = 'M' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['module'] += 1 - m = _MODULE.match(parameters[0]) - if m is None: - return _create_error( - f'M({parameters[0]!r})', f'parameter {parameters[0]!r} is not a FQCN') - fqcn = f'{m.group(1)}.{m.group(2)}.{m.group(3)}' - url = html_escape(f'../../{m.group(1)}/{m.group(2)}/{m.group(3)}_module.html') - return f"{html_escape(fqcn)}" - - -class _Plugin(Command): - command = 'P' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['plugin'] += 1 - m = _PLUGIN.match(parameters[0]) - if m is None: - return _create_error( - f'P({parameters[0]!r})', - f'parameter {parameters[0]!r} is not of the form FQCN#type') - fqcn = f'{m.group(1)}.{m.group(2)}.{m.group(3)}' - plugin_type = m.group(4) - url = html_escape(f'../../{m.group(1)}/{m.group(2)}/{m.group(3)}_{plugin_type}.html') - cssclass = f'plugin-{html_escape(plugin_type)}' - return f"{html_escape(fqcn)}" - - -class _URL(Command): - command = 'U' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['url'] += 1 - url = parameters[0] - return f"{html_escape(_escape_url(url))}" - - -class _Link(Command): - command = 'L' - parameter_count = 2 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['link'] += 1 - url = parameters[1] - return f"{html_escape(parameters[0])}" - - -class _Ref(Command): - command = 'R' - parameter_count = 2 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['ref'] += 1 - return f"{html_escape(parameters[0])}" - - -class _Const(Command): - command = 'C' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['const'] += 1 - # Escaping does not work in double backticks, so we use the :literal: role instead - return f"{html_escape(parameters[0])}" - - -class _OptionName(Command): - command = 'O' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['option-name'] += 1 - if context.plugin_fqcn is None or context.plugin_type is None: - raise Exception('The markup O(...) cannot be used outside a plugin or role') - text = parameters[0] - try: - plugin_fqcn, plugin_type, option_link, option, value = parse_option( - text, context.plugin_fqcn, context.plugin_type, require_plugin=False) - except ValueError as exc: - return _create_error(f'O({text})', str(exc)) - if value is None: - cls = 'ansible-option' - text = f'{option}' - strong_start = '' - strong_end = '' - else: - cls = 'ansible-option-value' - text = f'{option}={value}' - strong_start = '' - strong_end = '' - if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2: - # TODO: handle role arguments (entrypoint!) - namespace, name, plugin = plugin_fqcn.split('.', 2) - url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html' - fragment = f'parameter-{quote(option_link.replace(".", "/"))}' - link_start = ( - f'' - '' - ) - link_end = '' - else: - link_start = '' - link_end = '' - return ( - f'' - f'{strong_start}{link_start}{html_escape(text)}{link_end}{strong_end}' - ) - - -class _OptionValue(Command): - command = 'V' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['option-value'] += 1 - text = parameters[0] - return f'{html_escape(text)}' - - -class _EnvVariable(Command): - command = 'E' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['environment-var'] += 1 - text = parameters[0] - return f'{html_escape(text)}' - - -class _RetValue(Command): - command = 'RV' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['return-value'] += 1 - if context.plugin_fqcn is None or context.plugin_type is None: - raise Exception('The markup RV(...) cannot be used outside a plugin or role') - text = parameters[0] - try: - plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value( - text, context.plugin_fqcn, context.plugin_type, require_plugin=False) - except ValueError as exc: - return _create_error(f'RV({text})', str(exc)) - cls = 'ansible-return-value' - if value is None: - text = f'{rv}' - else: - text = f'{rv}={value}' - if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2: - namespace, name, plugin = plugin_fqcn.split('.', 2) - url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html' - fragment = f'return-{quote(rv_link.replace(".", "/"))}' - link_start = ( - f'' - '' - ) - link_end = '' - else: - link_start = '' - link_end = '' - return ( - f'{link_start}' - f'{html_escape(text)}{link_end}' - ) - - -class _HorizontalLine(Command): - command = 'HORIZONTALLINE' - parameter_count = 0 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['ruler'] += 1 - return '
' - - -_COMMAND_SET = CommandSet([ - _Italic(), - _Bold(), - _Module(), - _Plugin(), - _URL(), - _Link(), - _Ref(), - _Const(), - _OptionName(), - _OptionValue(), - _EnvVariable(), - _RetValue(), - _HorizontalLine(), -]) - - -@pass_context -def html_ify(context: Context, text: str, - *, - plugin_fqcn: t.Optional[str] = None, - plugin_type: t.Optional[str] = None) -> str: - ''' convert symbols like I(this is in italics) to valid HTML ''' - flog = mlog.fields(func='html_ify') - flog.fields(text=text).debug('Enter') - - our_context = _Context( - context, - plugin_fqcn=plugin_fqcn, - plugin_type=plugin_type, - ) - - try: - text = convert_text(text, _COMMAND_SET, html_escape, our_context) - except Exception as exc: # pylint:disable=broad-except - return _create_error(text, str(exc)) - - flog.fields(counts=our_context.counts).info('Number of macros converted to html equivalents') - flog.debug('Leave') - return text diff --git a/src/antsibull_docs/jinja2/parser.py b/src/antsibull_docs/jinja2/parser.py deleted file mode 100644 index 7231592b..00000000 --- a/src/antsibull_docs/jinja2/parser.py +++ /dev/null @@ -1,186 +0,0 @@ -# Author: Felix Fontein -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or -# https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: 2022, Ansible Project -""" -Parser for formatted texts. -""" - -import abc -import re -import typing as t - -_ESCAPE_OR_COMMA = re.compile(r'\\(.)| *(,) *') -_ESCAPE_OR_CLOSING = re.compile(r'\\(.)|([)])') - - -class ParsingException(Exception): - pass - - -class Command(abc.ABC): - command: str - parameter_count: int - escaped_content: bool - - @abc.abstractmethod - def handle(self, parameters: t.List[str], context: t.Any) -> str: - pass - - -class CommandSet: - _command_map: t.Mapping[str, Command] - _group_map: t.Mapping[str, Command] - _re: 're.Pattern' # on Python 3.6 the type is called differently - - def __init__(self, commands: t.List[Command]): - group_map = {} - command_map = {} - command_names = [] - for command in commands: - if command.command in command_map: - raise Exception( - f'Command "{command.command}" appears multiple times in parameter list') - command_map[command.command] = command - if command.parameter_count == 0: - command_names.append(fr'{re.escape(command.command)}\b') - group_map[command.command] = command - else: - command_names.append(fr'{re.escape(command.command)}\(') - group_map[f'{command.command}('] = command - self._command_map = command_map - self._group_map = group_map - if command_names: - command_re = '|'.join(command_names) - self._re = re.compile(fr'\b({command_re})') - else: - self._re = re.compile(r' ^') # never matches - - def find_next_command(self, text: str, start_index: int) -> t.Optional[t.Tuple[int, Command]]: - match = self._re.search(text, pos=start_index) - if not match: - return None - return match.start(1), self._group_map[match.group(1)] - - -class CommandData(t.NamedTuple): - command: Command - parameters: t.List[str] - - -Part = t.Union[str, CommandData] - - -def parse_parameters_escaped(text: str, index: int, command: Command, - command_start: int) -> t.Tuple[int, t.List[str]]: - result = [] - parameter_count = command.parameter_count - while parameter_count > 1: - parameter_count -= 1 - value = [] - while True: - match = _ESCAPE_OR_COMMA.search(text, pos=index) - if not match: - raise ParsingException( - f'Cannot find comma separating ' - f'parameter {command.parameter_count - parameter_count}' - f' from the next one for command "{command.command}"' - f' starting at index {command_start} in {text!r}' - ) - value.append(text[index:match.start(0)]) - index = match.end(0) - if match.group(1): - value.append(match.group(1)) - else: - break - result.append(''.join(value)) - value = [] - while True: - match = _ESCAPE_OR_CLOSING.search(text, pos=index) - if not match: - raise ParsingException( - f'Cannot find ")" closing after the last parameter for' - f' command "{command.command}" starting at index {command_start} in {text!r}' - ) - value.append(text[index:match.start(0)]) - index = match.end(0) - if match.group(1): - value.append(match.group(1)) - else: - break - result.append(''.join(value)) - return index, result - - -def parse_parameters_unescaped(text: str, index: int, command: Command, - command_start: int) -> t.Tuple[int, t.List[str]]: - result = [] - first = True - parameter_count = command.parameter_count - while parameter_count > 1: - parameter_count -= 1 - next_index = text.find(',', index) - if next_index < 0: - raise ParsingException( - f'Cannot find comma separating ' - f'parameter {command.parameter_count - parameter_count}' - f' from the next one for command "{command.command}"' - f' starting at index {command_start} in {text!r}' - ) - parameter = text[index:next_index].rstrip(' ') - if not first: - parameter = parameter.lstrip(' ') - else: - first = False - result.append(parameter) - index = next_index + 1 - next_index = text.find(')', index) - if next_index < 0: - raise ParsingException( - f'Cannot find ")" closing after the last parameter for' - f' command "{command.command}" starting at index {command_start} in {text!r}' - ) - parameter = text[index:next_index] - if not first: - parameter = parameter.lstrip(' ') - result.append(parameter) - index = next_index + 1 - return index, result - - -def parse_text(text: str, commands: CommandSet) -> t.List[Part]: - result: t.List[Part] = [] - index = 0 - length = len(text) - while index < length: - next_command = commands.find_next_command(text, index) - if next_command is None: - result.append(text[index:]) - break - command_start, command = next_command - if command_start > index: - result.append(text[index:command_start]) - index = command_start + len(command.command) - if command.parameter_count == 0: - result.append(CommandData(command=command, parameters=[])) - continue - index += 1 - if command.escaped_content: - index, parameters = parse_parameters_escaped(text, index, command, command_start) - else: - index, parameters = parse_parameters_unescaped(text, index, command, command_start) - result.append(CommandData(command=command, parameters=parameters)) - return result - - -def convert_text(text: str, commands: CommandSet, process_other_text: t.Callable[[str], str], - context: t.Any,) -> str: - parts = parse_text(text, commands) - result = [] - for part in parts: - if isinstance(part, str): - result.append(process_other_text(part)) - else: - result.append(part.command.handle(part.parameters, context)) - return ''.join(result) diff --git a/src/antsibull_docs/jinja2/rstify.py b/src/antsibull_docs/jinja2/rstify.py deleted file mode 100644 index 865026be..00000000 --- a/src/antsibull_docs/jinja2/rstify.py +++ /dev/null @@ -1,290 +0,0 @@ -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or -# https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: 2022, Ansible Project -""" -rstify Jinja2 filter for use in Ansible documentation. -""" - -import re -import typing as t -from urllib.parse import quote - -from antsibull_core.logging import log -from jinja2.runtime import Context -from jinja2.utils import pass_context - -from ..semantic_helper import augment_plugin_name_type -from .filters import extract_plugin_data -from .parser import Command, CommandSet, convert_text - -mlog = log.fields(mod=__name__) - -_MODULE = re.compile(r"^([^).]+)\.([^).]+)\.([^)]+)$") -_PLUGIN = re.compile(r"^([^).]+)\.([^).]+)\.([^)]+)#([a-z]+)$") - - -def rst_escape(value: t.Any, escape_ending_whitespace=False) -> str: - ''' make sure value is converted to a string, and RST special characters are escaped ''' - - if not isinstance(value, str): - value = str(value) - - value = value.replace('\\', '\\\\') - value = value.replace('<', '\\<') - value = value.replace('>', '\\>') - value = value.replace('_', '\\_') - value = value.replace('*', '\\*') - value = value.replace('`', '\\`') - - if escape_ending_whitespace and value.endswith(' '): - value = value + '\\ ' - if escape_ending_whitespace and value.startswith(' '): - value = '\\ ' + value - - return value - - -def rst_code(value: str) -> str: - ''' Write value as :code:`...` RST construct. ''' - if not isinstance(value, str): - value = str(value) - return f':code:`{rst_escape(value, escape_ending_whitespace=True)}`' - - -def _escape_url(url: str) -> str: - # We include '<>[]{}' in safe to allow urls such as 'https://:[PORT]/v{version}/' to - # remain unmangled by percent encoding - return quote(url, safe=':/#?%<>[]{}') - - -def _create_error(text: str, error: str) -> str: - text = f':literal:`{rst_escape(text, escape_ending_whitespace=True)}`' - error_msg = f':strong:`{rst_escape(error, escape_ending_whitespace=True)}`' - return f"\\ :strong:`ERROR while parsing` {text}\\ : {error_msg}\\ " - - -class _Context: - j2_context: Context - counts: t.Dict[str, int] - plugin_fqcn: t.Optional[str] - plugin_type: t.Optional[str] - - def __init__(self, j2_context: Context, - plugin_fqcn: t.Optional[str] = None, - plugin_type: t.Optional[str] = None): - self.j2_context = j2_context - self.counts = { - 'italic': 0, - 'bold': 0, - 'module': 0, - 'plugin': 0, - 'link': 0, - 'url': 0, - 'ref': 0, - 'const': 0, - 'option-name': 0, - 'option-value': 0, - 'environment-var': 0, - 'return-value': 0, - 'ruler': 0, - } - self.plugin_fqcn, self.plugin_type = extract_plugin_data( - j2_context, plugin_fqcn=plugin_fqcn, plugin_type=plugin_type) - - -# In the following, we make heavy use of escaped whitespace ("\ ") being removed from the output. -# See -# https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#character-level-inline-markup-1 -# for further information. - - -class _Italic(Command): - command = 'I' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['italic'] += 1 - return f"\\ :emphasis:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " - - -class _Bold(Command): - command = 'B' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['bold'] += 1 - return f"\\ :strong:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " - - -class _Module(Command): - command = 'M' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['module'] += 1 - m = _MODULE.match(parameters[0]) - if m is None: - return _create_error( - f'M({parameters[0]!r})', f'parameter {parameters[0]!r} is not a FQCN') - fqcn = f'{m.group(1)}.{m.group(2)}.{m.group(3)}' - return f"\\ :ref:`{rst_escape(fqcn)} `\\ " - - -class _Plugin(Command): - command = 'P' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['plugin'] += 1 - m = _PLUGIN.match(parameters[0]) - if m is None: - return _create_error( - f'P({parameters[0]!r})', - f'parameter {parameters[0]!r} is not of the form FQCN#type') - fqcn = f'{m.group(1)}.{m.group(2)}.{m.group(3)}' - plugin_type = m.group(4) - return f"\\ :ref:`{rst_escape(fqcn)} `\\ " - - -class _URL(Command): - command = 'U' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['url'] += 1 - return f"\\ {_escape_url(parameters[0])}\\ " - - -class _Link(Command): - command = 'L' - parameter_count = 2 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['link'] += 1 - return f"\\ `{rst_escape(parameters[0])} <{_escape_url(parameters[1])}>`__\\ " - - -class _Ref(Command): - command = 'R' - parameter_count = 2 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['ref'] += 1 - return f"\\ :ref:`{rst_escape(parameters[0])} <{parameters[1]}>`\\ " - - -class _Const(Command): - command = 'C' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['const'] += 1 - # Escaping does not work in double backticks, so we use the :literal: role instead - return f"\\ :literal:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " - - -class _OptionName(Command): - command = 'O' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['option-name'] += 1 - if context.plugin_fqcn is None or context.plugin_type is None: - raise Exception('The markup O(...) cannot be used outside a plugin or role') - text = augment_plugin_name_type(parameters[0], context.plugin_fqcn, context.plugin_type) - return f"\\ :ansopt:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " - - -class _OptionValue(Command): - command = 'V' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['option-value'] += 1 - return f"\\ :ansval:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " - - -class _EnvVariable(Command): - command = 'E' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['environment-var'] += 1 - return f"\\ :envvar:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ " - - -class _RetValue(Command): - command = 'RV' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['return-value'] += 1 - if context.plugin_fqcn is None or context.plugin_type is None: - raise Exception('The markup RV(...) cannot be used outside a plugin or role') - text = augment_plugin_name_type(parameters[0], context.plugin_fqcn, context.plugin_type) - return f"\\ :ansretval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " - - -class _HorizontalLine(Command): - command = 'HORIZONTALLINE' - parameter_count = 0 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - context.counts['ruler'] += 1 - return '\n\n.. raw:: html\n\n
\n\n' - - -_COMMAND_SET = CommandSet([ - _Italic(), - _Bold(), - _Module(), - _Plugin(), - _URL(), - _Link(), - _Ref(), - _Const(), - _OptionName(), - _OptionValue(), - _EnvVariable(), - _RetValue(), - _HorizontalLine(), -]) - - -@pass_context -def rst_ify(context: Context, text: str, - *, - plugin_fqcn: t.Optional[str] = None, - plugin_type: t.Optional[str] = None) -> str: - ''' convert symbols like I(this is in italics) to valid restructured text ''' - flog = mlog.fields(func='rst_ify') - flog.fields(text=text).debug('Enter') - - our_context = _Context( - context, - plugin_fqcn=plugin_fqcn, - plugin_type=plugin_type, - ) - - try: - text = convert_text(text, _COMMAND_SET, rst_escape, our_context) - except Exception as exc: # pylint:disable=broad-except - return _create_error(text, str(exc)) - - flog.fields(counts=our_context.counts).info('Number of macros converted to rst equivalents') - flog.debug('Leave') - return text diff --git a/src/antsibull_docs/markup/__init__.py b/src/antsibull_docs/markup/__init__.py new file mode 100644 index 00000000..7547f6e1 --- /dev/null +++ b/src/antsibull_docs/markup/__init__.py @@ -0,0 +1,4 @@ +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2020, Ansible Project diff --git a/src/antsibull_docs/markup/_counter.py b/src/antsibull_docs/markup/_counter.py new file mode 100644 index 00000000..a2414571 --- /dev/null +++ b/src/antsibull_docs/markup/_counter.py @@ -0,0 +1,85 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project +""" +Count markup instructions in parsed markup. +""" + +import typing as t + +from . import dom + + +class _Counter(dom.Walker): + counts: t.Dict[str, int] + + def __init__(self): + self.counts = { + 'italic': 0, + 'bold': 0, + 'module': 0, + 'plugin': 0, + 'link': 0, + 'url': 0, + 'ref': 0, + 'const': 0, + 'option-name': 0, + 'option-value': 0, + 'environment-var': 0, + 'return-value': 0, + 'ruler': 0, + } + + def process_error(self, part: dom.ErrorPart) -> None: + pass + + def process_bold(self, part: dom.BoldPart) -> None: + self.counts['bold'] += 1 + + def process_code(self, part: dom.CodePart) -> None: + self.counts['const'] += 1 + + def process_horizontal_line(self, part: dom.HorizontalLinePart) -> None: + self.counts['ruler'] += 1 + + def process_italic(self, part: dom.ItalicPart) -> None: + self.counts['italic'] += 1 + + def process_link(self, part: dom.LinkPart) -> None: + self.counts['link'] += 1 + + def process_module(self, part: dom.ModulePart) -> None: + self.counts['module'] += 1 + + def process_rst_ref(self, part: dom.RSTRefPart) -> None: + self.counts['ref'] += 1 + + def process_url(self, part: dom.URLPart) -> None: + self.counts['url'] += 1 + + def process_text(self, part: dom.TextPart) -> None: + pass + + def process_env_variable(self, part: dom.EnvVariablePart) -> None: + self.counts['environment-var'] += 1 + + def process_option_name(self, part: dom.OptionNamePart) -> None: + self.counts['option-name'] += 1 + + def process_option_value(self, part: dom.OptionValuePart) -> None: + self.counts['option-value'] += 1 + + def process_plugin(self, part: dom.PluginPart) -> None: + self.counts['plugin'] += 1 + + def process_return_value(self, part: dom.ReturnValuePart) -> None: + self.counts['return-value'] += 1 + + +def count(paragraphs: t.Sequence[dom.Paragraph]) -> t.Dict[str, int]: + counter = _Counter() + for paragraph in paragraphs: + dom.walk(paragraph, counter) + return counter.counts diff --git a/src/antsibull_docs/markup/_parser_impl.py b/src/antsibull_docs/markup/_parser_impl.py new file mode 100644 index 00000000..21ed5ac4 --- /dev/null +++ b/src/antsibull_docs/markup/_parser_impl.py @@ -0,0 +1,87 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project +""" +Internal parsing code. +""" + +import re +import typing as t + +_ESCAPE_OR_COMMA = re.compile(r'\\(.)| *(,) *') +_ESCAPE_OR_CLOSING = re.compile(r'\\(.)|([)])') + + +def parse_parameters_escaped(text: str, index: int, parameter_count: int, + ) -> t.Tuple[t.List[str], int, t.Optional[str]]: + result: t.List[str] = [] + parameters_left = parameter_count + while parameters_left > 1: + parameters_left -= 1 + value: t.List[str] = [] + while True: + match = _ESCAPE_OR_COMMA.search(text, pos=index) + if not match: + result.append(''.join(value)) + return ( + result, + len(text), + 'Cannot find comma separating parameter' + f' {parameter_count - parameters_left} from the next one' + ) + value.append(text[index:match.start(0)]) + index = match.end(0) + if match.group(1): + value.append(match.group(1)) + else: + break + result.append(''.join(value)) + value = [] + while True: + match = _ESCAPE_OR_CLOSING.search(text, pos=index) + if not match: + result.append(''.join(value)) + return result, len(text), 'Cannot find closing ")" after last parameter' + value.append(text[index:match.start(0)]) + index = match.end(0) + if match.group(1): + value.append(match.group(1)) + else: + break + result.append(''.join(value)) + return result, index, None + + +def parse_parameters_unescaped(text: str, index: int, parameter_count: int, + ) -> t.Tuple[t.List[str], int, t.Optional[str]]: + result: t.List[str] = [] + first = True + parameters_left = parameter_count + while parameters_left > 1: + parameters_left -= 1 + next_index = text.find(',', index) + if next_index < 0: + return ( + result, + len(text), + 'Cannot find comma separating parameter' + f' {parameter_count - parameters_left} from the next one' + ) + parameter = text[index:next_index].rstrip(' ') + if not first: + parameter = parameter.lstrip(' ') + else: + first = False + result.append(parameter) + index = next_index + 1 + next_index = text.find(')', index) + if next_index < 0: + return result, len(text), 'Cannot find closing ")" after last parameter' + parameter = text[index:next_index] + if not first: + parameter = parameter.lstrip(' ') + result.append(parameter) + index = next_index + 1 + return result, index, None diff --git a/src/antsibull_docs/markup/dom.py b/src/antsibull_docs/markup/dom.py new file mode 100644 index 00000000..52403a7e --- /dev/null +++ b/src/antsibull_docs/markup/dom.py @@ -0,0 +1,308 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project +""" +DOM classes used by parser. +""" + +import abc +import sys + +from enum import Enum +from typing import NamedTuple + +import typing as t + + +if sys.version_info >= (3, 8): + ErrorType = t.Union[t.Literal['ignore'], t.Literal['message'], t.Literal['exception']] +else: + # Python 3.6/3.7 do not have t.Literal + ErrorType = str # pragma: no cover + + +class PluginIdentifier(NamedTuple): + fqcn: str + type: str + + +class PartType(Enum): + ERROR = 0 + BOLD = 1 + CODE = 2 + HORIZONTAL_LINE = 3 + ITALIC = 4 + LINK = 5 + MODULE = 6 + RST_REF = 7 + URL = 8 + TEXT = 9 + ENV_VARIABLE = 10 + OPTION_NAME = 11 + OPTION_VALUE = 12 + PLUGIN = 13 + RETURN_VALUE = 14 + + +class TextPart(NamedTuple): + text: str + type: 't.Literal[PartType.TEXT]' = PartType.TEXT + + +class ItalicPart(NamedTuple): + text: str + type: 't.Literal[PartType.ITALIC]' = PartType.ITALIC + + +class BoldPart(NamedTuple): + text: str + type: 't.Literal[PartType.BOLD]' = PartType.BOLD + + +class ModulePart(NamedTuple): + fqcn: str + type: 't.Literal[PartType.MODULE]' = PartType.MODULE + + +class PluginPart(NamedTuple): + plugin: PluginIdentifier + type: 't.Literal[PartType.PLUGIN]' = PartType.PLUGIN + + +class URLPart(NamedTuple): + url: str + type: 't.Literal[PartType.URL]' = PartType.URL + + +class LinkPart(NamedTuple): + text: str + url: str + type: 't.Literal[PartType.LINK]' = PartType.LINK + + +class RSTRefPart(NamedTuple): + text: str + ref: str + type: 't.Literal[PartType.RST_REF]' = PartType.RST_REF + + +class CodePart(NamedTuple): + text: str + type: 't.Literal[PartType.CODE]' = PartType.CODE + + +class OptionNamePart(NamedTuple): + plugin: t.Optional[PluginIdentifier] + link: t.List[str] + name: str + value: t.Optional[str] + type: 't.Literal[PartType.OPTION_NAME]' = PartType.OPTION_NAME + + +class OptionValuePart(NamedTuple): + value: str + type: 't.Literal[PartType.OPTION_VALUE]' = PartType.OPTION_VALUE + + +class EnvVariablePart(NamedTuple): + name: str + type: 't.Literal[PartType.ENV_VARIABLE]' = PartType.ENV_VARIABLE + + +class ReturnValuePart(NamedTuple): + plugin: t.Optional[PluginIdentifier] + link: t.List[str] + name: str + value: t.Optional[str] + type: 't.Literal[PartType.RETURN_VALUE]' = PartType.RETURN_VALUE + + +class HorizontalLinePart(NamedTuple): + type: 't.Literal[PartType.HORIZONTAL_LINE]' = PartType.HORIZONTAL_LINE + + +class ErrorPart(NamedTuple): + message: str + type: 't.Literal[PartType.ERROR]' = PartType.ERROR + + +AnyPart = t.Union[ + TextPart, + ItalicPart, + BoldPart, + ModulePart, + PluginPart, + URLPart, + LinkPart, + RSTRefPart, + CodePart, + OptionNamePart, + OptionValuePart, + EnvVariablePart, + ReturnValuePart, + HorizontalLinePart, + ErrorPart, +] + + +Paragraph = t.List[AnyPart] + + +class Walker(abc.ABC): + ''' + Abstract base class for walker whose methods will be called for parts of a paragraph. + ''' + + @abc.abstractmethod + def process_error(self, part: ErrorPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_bold(self, part: BoldPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_code(self, part: CodePart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_horizontal_line(self, part: HorizontalLinePart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_italic(self, part: ItalicPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_link(self, part: LinkPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_module(self, part: ModulePart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_rst_ref(self, part: RSTRefPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_url(self, part: URLPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_text(self, part: TextPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_env_variable(self, part: EnvVariablePart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_option_name(self, part: OptionNamePart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_option_value(self, part: OptionValuePart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_plugin(self, part: PluginPart) -> None: + pass # pragma: no cover + + @abc.abstractmethod + def process_return_value(self, part: ReturnValuePart) -> None: + pass # pragma: no cover + + +class NoopWalker(Walker): + ''' + Concrete base class for walker whose methods will be called for parts of a paragraph. + The default implementation for every part will not do anything. + ''' + + def process_error(self, part: ErrorPart) -> None: + pass + + def process_bold(self, part: BoldPart) -> None: + pass + + def process_code(self, part: CodePart) -> None: + pass + + def process_horizontal_line(self, part: HorizontalLinePart) -> None: + pass + + def process_italic(self, part: ItalicPart) -> None: + pass + + def process_link(self, part: LinkPart) -> None: + pass + + def process_module(self, part: ModulePart) -> None: + pass + + def process_rst_ref(self, part: RSTRefPart) -> None: + pass + + def process_url(self, part: URLPart) -> None: + pass + + def process_text(self, part: TextPart) -> None: + pass + + def process_env_variable(self, part: EnvVariablePart) -> None: + pass + + def process_option_name(self, part: OptionNamePart) -> None: + pass + + def process_option_value(self, part: OptionValuePart) -> None: + pass + + def process_plugin(self, part: PluginPart) -> None: + pass + + def process_return_value(self, part: ReturnValuePart) -> None: + pass + + +# pylint:disable-next=too-many-branches +def walk(paragraph: Paragraph, walker: Walker) -> None: # noqa: C901 + ''' + Call the corresponding methods of a walker object for every part of the paragraph. + ''' + for part in paragraph: + if part.type == PartType.ERROR: + walker.process_error(t.cast(ErrorPart, part)) + elif part.type == PartType.BOLD: + walker.process_bold(t.cast(BoldPart, part)) + elif part.type == PartType.CODE: + walker.process_code(t.cast(CodePart, part)) + elif part.type == PartType.HORIZONTAL_LINE: + walker.process_horizontal_line(t.cast(HorizontalLinePart, part)) + elif part.type == PartType.ITALIC: + walker.process_italic(t.cast(ItalicPart, part)) + elif part.type == PartType.LINK: + walker.process_link(t.cast(LinkPart, part)) + elif part.type == PartType.MODULE: + walker.process_module(t.cast(ModulePart, part)) + elif part.type == PartType.RST_REF: + walker.process_rst_ref(t.cast(RSTRefPart, part)) + elif part.type == PartType.URL: + walker.process_url(t.cast(URLPart, part)) + elif part.type == PartType.TEXT: + walker.process_text(t.cast(TextPart, part)) + elif part.type == PartType.ENV_VARIABLE: + walker.process_env_variable(t.cast(EnvVariablePart, part)) + elif part.type == PartType.OPTION_NAME: + walker.process_option_name(t.cast(OptionNamePart, part)) + elif part.type == PartType.OPTION_VALUE: + walker.process_option_value(t.cast(OptionValuePart, part)) + elif part.type == PartType.PLUGIN: + walker.process_plugin(t.cast(PluginPart, part)) + elif part.type == PartType.RETURN_VALUE: + walker.process_return_value(t.cast(ReturnValuePart, part)) + else: + raise RuntimeError(f'Internal error: unknown type {part.type!r}') diff --git a/src/antsibull_docs/markup/format.py b/src/antsibull_docs/markup/format.py new file mode 100644 index 00000000..70da5d50 --- /dev/null +++ b/src/antsibull_docs/markup/format.py @@ -0,0 +1,211 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project +""" +Flexible formatting of DOM. +""" + +import abc + +import typing as t + +from . import dom + + +class LinkProvider(abc.ABC): + ''' + Provide URLs for objects, if available. + ''' + + def plugin_link(self, # pylint:disable=no-self-use + plugin: dom.PluginIdentifier, # pylint:disable=unused-argument + ) -> t.Optional[str]: + '''Provides a link to a plugin.''' + return None + + def plugin_option_like_link(self, # pylint:disable=no-self-use + plugin: dom.PluginIdentifier, # pylint:disable=unused-argument + # pylint:disable-next=unused-argument + what: "t.Union[t.Literal['option'], t.Literal['retval']]", + # pylint:disable-next=unused-argument + name: t.List[str], current_plugin: bool) -> t.Optional[str]: + '''Provides a link to a plugin's option or return value.''' + return None + + +class _DefaultLinkProvider(LinkProvider): + pass + + +class Formatter(abc.ABC): + ''' + Abstract base class for a formatter whose functions will be called for parts of a paragraph. + ''' + + @abc.abstractmethod + def format_error(self, part: dom.ErrorPart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_bold(self, part: dom.BoldPart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_code(self, part: dom.CodePart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_horizontal_line(self, part: dom.HorizontalLinePart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_italic(self, part: dom.ItalicPart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_link(self, part: dom.LinkPart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_module(self, part: dom.ModulePart, url: t.Optional[str]) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_rst_ref(self, part: dom.RSTRefPart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_url(self, part: dom.URLPart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_text(self, part: dom.TextPart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_env_variable(self, part: dom.EnvVariablePart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_option_name(self, part: dom.OptionNamePart, url: t.Optional[str]) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_option_value(self, part: dom.OptionValuePart) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_plugin(self, part: dom.PluginPart, url: t.Optional[str]) -> str: + pass # pragma: no cover + + @abc.abstractmethod + def format_return_value(self, part: dom.ReturnValuePart, url: t.Optional[str]) -> str: + pass # pragma: no cover + + +class _FormatWalker(dom.Walker): + ''' + Walker which calls a formatter's functions and stores the result in a list. + ''' + + destination: t.List[str] + formatter: Formatter + link_provider: LinkProvider + current_plugin: t.Optional[dom.PluginIdentifier] + + def __init__(self, destination: t.List[str], + formatter: Formatter, + link_provider: LinkProvider, + current_plugin: t.Optional[dom.PluginIdentifier]): + self.destination = destination + self.formatter = formatter + self.link_provider = link_provider + self.current_plugin = current_plugin + + def process_error(self, part: dom.ErrorPart) -> None: + self.destination.append(self.formatter.format_error(part)) + + def process_bold(self, part: dom.BoldPart) -> None: + self.destination.append(self.formatter.format_bold(part)) + + def process_code(self, part: dom.CodePart) -> None: + self.destination.append(self.formatter.format_code(part)) + + def process_horizontal_line(self, part: dom.HorizontalLinePart) -> None: + self.destination.append(self.formatter.format_horizontal_line(part)) + + def process_italic(self, part: dom.ItalicPart) -> None: + self.destination.append(self.formatter.format_italic(part)) + + def process_link(self, part: dom.LinkPart) -> None: + self.destination.append(self.formatter.format_link(part)) + + def process_module(self, part: dom.ModulePart) -> None: + url = self.link_provider.plugin_link(dom.PluginIdentifier(fqcn=part.fqcn, type='module')) + self.destination.append(self.formatter.format_module(part, url)) + + def process_rst_ref(self, part: dom.RSTRefPart) -> None: + self.destination.append(self.formatter.format_rst_ref(part)) + + def process_url(self, part: dom.URLPart) -> None: + self.destination.append(self.formatter.format_url(part)) + + def process_text(self, part: dom.TextPart) -> None: + self.destination.append(self.formatter.format_text(part)) + + def process_env_variable(self, part: dom.EnvVariablePart) -> None: + self.destination.append(self.formatter.format_env_variable(part)) + + def process_option_name(self, part: dom.OptionNamePart) -> None: + url = None + if part.plugin: + url = self.link_provider.plugin_option_like_link( + part.plugin, 'option', part.link, part.plugin == self.current_plugin) + self.destination.append(self.formatter.format_option_name(part, url)) + + def process_option_value(self, part: dom.OptionValuePart) -> None: + self.destination.append(self.formatter.format_option_value(part)) + + def process_plugin(self, part: dom.PluginPart) -> None: + url = self.link_provider.plugin_link(part.plugin) + self.destination.append(self.formatter.format_plugin(part, url)) + + def process_return_value(self, part: dom.ReturnValuePart) -> None: + url = None + if part.plugin: + url = self.link_provider.plugin_option_like_link( + part.plugin, 'retval', part.link, part.plugin == self.current_plugin) + self.destination.append(self.formatter.format_return_value(part, url)) + + +def format_paragraphs(paragraphs: t.Sequence[dom.Paragraph], + formatter: Formatter, + link_provider: t.Optional[LinkProvider] = None, + par_start: str = '', + par_end: str = '', + par_sep: str = '', + par_empty: str = '', + current_plugin: t.Optional[dom.PluginIdentifier] = None) -> str: + ''' + Apply the formatter to all parts of the given paragraphs, concatenate the results, + and insert start and end sequences for paragraphs and sequences between paragraphs. + + ``link_provider`` and ``current_plugin`` will be used to compute optional URLs + that will be passed to the formatter. + ''' + if link_provider is None: + link_provider = _DefaultLinkProvider() + result: t.List[str] = [] + walker = _FormatWalker(result, formatter, link_provider, current_plugin) + for paragraph in paragraphs: + if result: + result.append(par_sep) + result.append(par_start) + before_len = len(result) + dom.walk(paragraph, walker) + if before_len == len(result): + result.append(par_empty) + result.append(par_end) + return ''.join(result) diff --git a/src/antsibull_docs/markup/html.py b/src/antsibull_docs/markup/html.py new file mode 100644 index 00000000..57bb4a43 --- /dev/null +++ b/src/antsibull_docs/markup/html.py @@ -0,0 +1,237 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project +""" +HTML serialization. +""" + +import typing as t + +from html import escape as _html_escape +from urllib.parse import quote + +from . import dom +from .format import Formatter, LinkProvider, format_paragraphs as _format_paragraphs + + +def html_escape(text: str) -> str: + return _html_escape(text).replace('"', '"') + + +def _url_escape(url: str) -> str: + # We include '<>[]{}' in safe to allow urls such as 'https://:[PORT]/v{version}/' to + # remain unmangled by percent encoding + return quote(url, safe=':/#?%<>[]{}') + + +class AntsibullHTMLFormatter(Formatter): + @staticmethod + def _format_option_like(part: t.Union[dom.OptionNamePart, dom.ReturnValuePart], + url: t.Optional[str]) -> str: + link_start = '' + link_end = '' + if url: + link_start = ( + f'' + '' + ) + link_end = '' + strong_start = '' + strong_end = '' + if part.type == dom.PartType.OPTION_NAME: + if part.value is None: + cls = 'ansible-option' + strong_start = '' + strong_end = '' + else: + cls = 'ansible-option-value' + else: + cls = 'ansible-return-value' + if part.value is None: + text = part.name + else: + text = f'{part.name}={part.value}' + return ( + f'{strong_start}{link_start}' + f'{html_escape(text)}{link_end}{strong_end}' + ) + + def format_error(self, part: dom.ErrorPart) -> str: + return f'ERROR while parsing: {html_escape(part.message)}' + + def format_bold(self, part: dom.BoldPart) -> str: + return f'{html_escape(part.text)}' + + def format_code(self, part: dom.CodePart) -> str: + return f"{html_escape(part.text)}" + + def format_horizontal_line(self, part: dom.HorizontalLinePart) -> str: + return '
' + + def format_italic(self, part: dom.ItalicPart) -> str: + return f'{html_escape(part.text)}' + + def format_link(self, part: dom.LinkPart) -> str: + return f"{html_escape(part.text)}" + + def format_module(self, part: dom.ModulePart, url: t.Optional[str]) -> str: + if not url: + return f"{html_escape(part.fqcn)}" + return ( + f"" + f'{html_escape(part.fqcn)}' + ) + + def format_rst_ref(self, part: dom.RSTRefPart) -> str: + return f"{html_escape(part.text)}" + + def format_url(self, part: dom.URLPart) -> str: + return ( + f"" + f'{html_escape(_url_escape(part.url))}' + ) + + def format_text(self, part: dom.TextPart) -> str: + return html_escape(part.text) + + def format_env_variable(self, part: dom.EnvVariablePart) -> str: + return ( + '' + f'{html_escape(part.name)}' + ) + + def format_option_name(self, part: dom.OptionNamePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, url) + + def format_option_value(self, part: dom.OptionValuePart) -> str: + return f'{html_escape(part.value)}' + + def format_plugin(self, part: dom.PluginPart, url: t.Optional[str]) -> str: + if not url: + return f"{html_escape(part.plugin.fqcn)}" + return ( + f"" + f'{html_escape(part.plugin.fqcn)}' + ) + + def format_return_value(self, part: dom.ReturnValuePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, url) + + +class PlainHTMLFormatter(Formatter): + @staticmethod + def _format_option_like(part: t.Union[dom.OptionNamePart, dom.ReturnValuePart], + url: t.Optional[str]) -> str: + link_start = '' + link_end = '' + if url: + link_start = f'' + link_end = '' + strong_start = '' + strong_end = '' + if part.type == dom.PartType.OPTION_NAME and part.value is None: + strong_start = '' + strong_end = '' + if part.value is None: + text = part.name + else: + text = f'{part.name}={part.value}' + return f'{strong_start}{link_start}{html_escape(text)}{link_end}{strong_end}' + + def format_error(self, part: dom.ErrorPart) -> str: + return f'ERROR while parsing: {html_escape(part.message)}' + + def format_bold(self, part: dom.BoldPart) -> str: + return f'{html_escape(part.text)}' + + def format_code(self, part: dom.CodePart) -> str: + return f"{html_escape(part.text)}" + + def format_horizontal_line(self, part: dom.HorizontalLinePart) -> str: + return '
' + + def format_italic(self, part: dom.ItalicPart) -> str: + return f'{html_escape(part.text)}' + + def format_link(self, part: dom.LinkPart) -> str: + return f"{html_escape(part.text)}" + + def format_module(self, part: dom.ModulePart, url: t.Optional[str]) -> str: + if not url: + return f"{html_escape(part.fqcn)}" + return f"{html_escape(part.fqcn)}" + + def format_rst_ref(self, part: dom.RSTRefPart) -> str: + return f"{html_escape(part.text)}" + + def format_url(self, part: dom.URLPart) -> str: + return ( + f"" + f'{html_escape(_url_escape(part.url))}' + ) + + def format_text(self, part: dom.TextPart) -> str: + return html_escape(part.text) + + def format_env_variable(self, part: dom.EnvVariablePart) -> str: + return f'{html_escape(part.name)}' + + def format_option_name(self, part: dom.OptionNamePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, url) + + def format_option_value(self, part: dom.OptionValuePart) -> str: + return f'{html_escape(part.value)}' + + def format_plugin(self, part: dom.PluginPart, url: t.Optional[str]) -> str: + if not url: + return f"{html_escape(part.plugin.fqcn)}" + return f"{html_escape(part.plugin.fqcn)}" + + def format_return_value(self, part: dom.ReturnValuePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, url) + + +DEFAULT_ANTSIBULL_FORMATTER = AntsibullHTMLFormatter() +DEFAULT_PLAIN_FORMATTER = PlainHTMLFormatter() + + +def to_html(paragraphs: t.Sequence[dom.Paragraph], + formatter: Formatter = DEFAULT_ANTSIBULL_FORMATTER, + link_provider: t.Optional[LinkProvider] = None, + par_start: str = '

', + par_end: str = '

', + par_sep: str = '', + par_empty: str = '', + current_plugin: t.Optional[dom.PluginIdentifier] = None) -> str: + return _format_paragraphs( + paragraphs, + formatter=formatter, + link_provider=link_provider, + par_start=par_start, + par_end=par_end, + par_sep=par_sep, + par_empty=par_empty, + current_plugin=current_plugin, + ) + + +def to_html_plain(paragraphs: t.Sequence[dom.Paragraph], + formatter: Formatter = DEFAULT_PLAIN_FORMATTER, + link_provider: t.Optional[LinkProvider] = None, + par_start: str = '

', + par_end: str = '

', + par_sep: str = '', + par_empty: str = '', + current_plugin: t.Optional[dom.PluginIdentifier] = None) -> str: + return _format_paragraphs( + paragraphs, + formatter=formatter, + link_provider=link_provider, + par_start=par_start, + par_end=par_end, + par_sep=par_sep, + par_empty=par_empty, + current_plugin=current_plugin, + ) diff --git a/src/antsibull_docs/markup/htmlify.py b/src/antsibull_docs/markup/htmlify.py new file mode 100644 index 00000000..02c82baa --- /dev/null +++ b/src/antsibull_docs/markup/htmlify.py @@ -0,0 +1,53 @@ +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project +""" +htmlify Jinja2 filter for use in Ansible documentation. +""" + +import typing as t + +from urllib.parse import quote + +from . import dom +from ._counter import count as _count +from .parser import parse, Context +from .html import to_html +from .format import LinkProvider + + +class _HTMLLinkProvider(LinkProvider): + def plugin_link(self, plugin: dom.PluginIdentifier) -> t.Optional[str]: + name = '/'.join(plugin.fqcn.split('.', 2)) + return f'../../{name}_{plugin.type}.html' + + def plugin_option_like_link(self, + plugin: dom.PluginIdentifier, + what: "t.Union[t.Literal['option'], t.Literal['retval']]", + name: t.List[str], current_plugin: bool) -> t.Optional[str]: + base = '' if current_plugin else self.plugin_link(plugin) + w = 'parameter' if what == 'option' else 'return' + slug = quote('/'.join(name)) + return f'{base}#{w}-{slug}' + + +def html_ify(text: str, + *, + plugin_fqcn: t.Optional[str] = None, + plugin_type: t.Optional[str] = None) -> t.Tuple[str, t.Mapping[str, int]]: + ''' convert symbols like I(this is in italics) to valid HTML ''' + current_plugin: t.Optional[dom.PluginIdentifier] = None + if plugin_fqcn and plugin_type: + current_plugin = dom.PluginIdentifier(fqcn=plugin_fqcn, type=plugin_type) + paragraphs = parse(text, Context(current_plugin=current_plugin), errors='message') + link_provider = _HTMLLinkProvider() + text = to_html( + paragraphs, + link_provider=link_provider, + current_plugin=current_plugin, + par_start='', + par_end='', + ) + counts = _count(paragraphs) + return text, counts diff --git a/src/antsibull_docs/markup/md.py b/src/antsibull_docs/markup/md.py new file mode 100644 index 00000000..8b0b6eb7 --- /dev/null +++ b/src/antsibull_docs/markup/md.py @@ -0,0 +1,115 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project +""" +MarkDown serialization. +""" + +import re +import typing as t + +from . import dom +from .format import Formatter, LinkProvider, format_paragraphs as _format_paragraphs +from .html import _url_escape, html_escape as _html_escape + + +_MD_ESCAPE = re.compile(r'''([!"#$%&'()*+,:;<=>?@[\\\]^_`{|}~-])''') + + +def md_escape(text: str) -> str: + return _MD_ESCAPE.sub(r'\\\1', text) + + +class MDFormatter(Formatter): + @staticmethod + def _format_option_like(part: t.Union[dom.OptionNamePart, dom.ReturnValuePart], + url: t.Optional[str]) -> str: + link_start = '' + link_end = '' + if url: + link_start = f'' + link_end = '' + strong_start = '' + strong_end = '' + if part.type == dom.PartType.OPTION_NAME and part.value is None: + strong_start = '' + strong_end = '' + if part.value is None: + text = part.name + else: + text = f'{part.name}={part.value}' + return f'{strong_start}{link_start}{md_escape(text)}{link_end}{strong_end}' + + def format_error(self, part: dom.ErrorPart) -> str: + return f'ERROR while parsing: {md_escape(part.message)}' + + def format_bold(self, part: dom.BoldPart) -> str: + return f'{md_escape(part.text)}' + + def format_code(self, part: dom.CodePart) -> str: + return f'{md_escape(part.text)}' + + def format_horizontal_line(self, part: dom.HorizontalLinePart) -> str: + return '
' + + def format_italic(self, part: dom.ItalicPart) -> str: + return f'{md_escape(part.text)}' + + def format_link(self, part: dom.LinkPart) -> str: + return f'[{md_escape(part.text)}]({md_escape(_url_escape(part.url))})' + + def format_module(self, part: dom.ModulePart, url: t.Optional[str]) -> str: + if url: + return f'[{md_escape(part.fqcn)}]({md_escape(_url_escape(url))})' + return md_escape(part.fqcn) + + def format_rst_ref(self, part: dom.RSTRefPart) -> str: + return md_escape(part.text) + + def format_url(self, part: dom.URLPart) -> str: + return f'[{md_escape(_url_escape(part.url))}]({md_escape(_url_escape(part.url))})' + + def format_text(self, part: dom.TextPart) -> str: + return md_escape(part.text) + + def format_env_variable(self, part: dom.EnvVariablePart) -> str: + return f'{md_escape(part.name)}' + + def format_option_name(self, part: dom.OptionNamePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, url) + + def format_option_value(self, part: dom.OptionValuePart) -> str: + return f'{md_escape(part.value)}' + + def format_plugin(self, part: dom.PluginPart, url: t.Optional[str]) -> str: + if url: + return f'[{md_escape(part.plugin.fqcn)}]({md_escape(_url_escape(url))})' + return md_escape(part.plugin.fqcn) + + def format_return_value(self, part: dom.ReturnValuePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, url) + + +DEFAULT_FORMATTER = MDFormatter() + + +def to_md(paragraphs: t.Sequence[dom.Paragraph], + formatter: Formatter = DEFAULT_FORMATTER, + link_provider: t.Optional[LinkProvider] = None, + par_start: str = '', + par_end: str = '', + par_sep: str = '\n\n', + par_empty: str = ' ', + current_plugin: t.Optional[dom.PluginIdentifier] = None) -> str: + return _format_paragraphs( + paragraphs, + formatter=formatter, + link_provider=link_provider, + par_start=par_start, + par_end=par_end, + par_sep=par_sep, + par_empty=par_empty, + current_plugin=current_plugin, + ) diff --git a/src/antsibull_docs/markup/parser.py b/src/antsibull_docs/markup/parser.py new file mode 100644 index 00000000..90f5817b --- /dev/null +++ b/src/antsibull_docs/markup/parser.py @@ -0,0 +1,331 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project +""" +Parser for formatted texts. +""" + +import abc +import re +import typing as t + +from . import dom +from ._parser_impl import parse_parameters_escaped, parse_parameters_unescaped + + +_IGNORE_MARKER = 'ignore:' +_ARRAY_STUB_RE = re.compile(r'\[([^\]]*)\]') +_FQCN_TYPE_PREFIX_RE = re.compile(r'^([^.]+\.[^.]+\.[^#]+)#([^:]+):(.*)$') +_FQCN = re.compile(r'^[a-z0-9_]+\.[a-z0-9_]+(?:\.[a-z0-9_]+)+$') +_PLUGIN_TYPE = re.compile(r'^[a-z_]+$') + + +def _is_fqcn(text: str) -> bool: + return _FQCN.match(text) is not None + + +def _is_plugin_type(text: str) -> bool: + # We do not want to hard-code a list of valid plugin types that might be inaccurate, so we + # simply check whether this is a valid kind of Python identifier usually used for plugin + # types. If ansible-core ever adds one with digits, we'll have to update this. + return _PLUGIN_TYPE.match(text) is not None + + +class Context(t.NamedTuple): + current_plugin: t.Optional[dom.PluginIdentifier] = None + + +class CommandParser(abc.ABC): + command: str + parameters: int + escaped_arguments: bool + + def __init__(self, command: str, parameters: int, escaped_arguments: bool = False): + self.command = command + self.parameters = parameters + self.escaped_arguments = escaped_arguments + + @abc.abstractmethod + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + pass # pragma: no cover + + +class CommandParserEx(CommandParser): + old_markup: bool + + def __init__(self, command: str, parameters: int, + escaped_arguments: bool = False, old_markup: bool = False): + super().__init__(command, parameters, escaped_arguments) + self.old_markup = old_markup + + +# Classic Ansible docs markup: + + +class _Italics(CommandParserEx): + def __init__(self): + super().__init__('I', 1, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + return dom.ItalicPart(text=parameters[0]) + + +class _Bold(CommandParserEx): + def __init__(self): + super().__init__('B', 1, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + return dom.BoldPart(text=parameters[0]) + + +class _Module(CommandParserEx): + def __init__(self): + super().__init__('M', 1, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + fqcn = parameters[0] + if not _is_fqcn(fqcn): + raise ValueError(f'Module name "{fqcn}" is not a FQCN') + return dom.ModulePart(fqcn=fqcn) + + +class _URL(CommandParserEx): + def __init__(self): + super().__init__('U', 1, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + return dom.URLPart(url=parameters[0]) + + +class _Link(CommandParserEx): + def __init__(self): + super().__init__('L', 2, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + text = parameters[0] + url = parameters[1] + return dom.LinkPart(text=text, url=url) + + +class _RSTRef(CommandParserEx): + def __init__(self): + super().__init__('R', 2, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + text = parameters[0] + ref = parameters[1] + return dom.RSTRefPart(text=text, ref=ref) + + +class _Code(CommandParserEx): + def __init__(self): + super().__init__('C', 1, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + return dom.CodePart(text=parameters[0]) + + +class _HorizontalLine(CommandParserEx): + def __init__(self): + super().__init__('HORIZONTALLINE', 0, old_markup=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + return dom.HorizontalLinePart() + + +# Semantic Ansible docs markup: + + +def _parse_option_like(text: str, + context: Context, + ) -> t.Tuple[t.Optional[dom.PluginIdentifier], + t.List[str], str, t.Optional[str]]: + value = None + if '=' in text: + text, value = text.split('=', 1) + m = _FQCN_TYPE_PREFIX_RE.match(text) + if m: + plugin_fqcn = m.group(1) + plugin_type = m.group(2) + if not _is_fqcn(plugin_fqcn): + raise ValueError(f'Plugin name "{plugin_fqcn}" is not a FQCN') + if not _is_plugin_type(plugin_type): + raise ValueError(f'Plugin type "{plugin_type}" is not valid') + plugin_identifier = dom.PluginIdentifier(fqcn=plugin_fqcn, type=plugin_type) + text = m.group(3) + elif text.startswith(_IGNORE_MARKER): + plugin_identifier = None + text = text[len(_IGNORE_MARKER):] + else: + plugin_identifier = context.current_plugin + if ':' in text or '#' in text: + raise ValueError(f'Invalid option/return value name "{text}"') + return ( + plugin_identifier, + _ARRAY_STUB_RE.sub('', text).split('.'), + text, + value, + ) + + +class _Plugin(CommandParserEx): + def __init__(self): + super().__init__('P', 1, escaped_arguments=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + name = parameters[0] + if '#' not in name: + raise ValueError(f'Parameter "{name}" is not of the form FQCN#type') + fqcn, ptype = name.split('#', 1) + if not _is_fqcn(fqcn): + raise ValueError(f'Plugin name "{fqcn}" is not a FQCN') + if not _is_plugin_type(ptype): + raise ValueError(f'Plugin type "{ptype}" is not valid') + return dom.PluginPart(plugin=dom.PluginIdentifier(fqcn=fqcn, type=ptype)) + + +class _EnvVar(CommandParserEx): + def __init__(self): + super().__init__('E', 1, escaped_arguments=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + return dom.EnvVariablePart(name=parameters[0]) + + +class _OptionValue(CommandParserEx): + def __init__(self): + super().__init__('V', 1, escaped_arguments=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + return dom.OptionValuePart(value=parameters[0]) + + +class _OptionName(CommandParserEx): + def __init__(self): + super().__init__('O', 1, escaped_arguments=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + plugin, link, name, value = _parse_option_like(parameters[0], context) + return dom.OptionNamePart(plugin=plugin, link=link, name=name, value=value) + + +class _ReturnValue(CommandParserEx): + def __init__(self): + super().__init__('RV', 1, escaped_arguments=True) + + def parse(self, parameters: t.List[str], context: Context) -> dom.AnyPart: + plugin, link, name, value = _parse_option_like(parameters[0], context) + return dom.ReturnValuePart(plugin=plugin, link=link, name=name, value=value) + + +_COMMANDS = [ + _Italics(), + _Bold(), + _Module(), + _URL(), + _Link(), + _RSTRef(), + _Code(), + _HorizontalLine(), + _Plugin(), + _EnvVar(), + _OptionValue(), + _OptionName(), + _ReturnValue(), +] + + +def _command_re(command: CommandParser) -> str: + return r'\b' + re.escape(command.command) + (r'\b' if command.parameters == 0 else r'\(') + + +class Parser: + _group_map: t.Mapping[str, CommandParser] + _re: 're.Pattern' # on Python 3.6 the type is called differently + + def __init__(self, commands: t.Sequence[CommandParser]): + self._group_map = {cmd.command + ('(' if cmd.parameters else ''): cmd for cmd in commands} + if commands: + self._re = re.compile('(' + '|'.join([_command_re(cmd) for cmd in commands]) + ')') + else: + self._re = re.compile('x^') # does not match anything + + @staticmethod + def _parse_command(result: dom.Paragraph, text: str, cmd: CommandParser, index: int, + end_index: int, context: Context, errors: dom.ErrorType, where: str + ) -> int: + args: t.List[str] + error: t.Optional[str] = None + if cmd.parameters == 0: + args = [] + elif cmd.escaped_arguments: + args, end_index, error = parse_parameters_escaped(text, end_index, cmd.parameters) + else: + args, end_index, error = parse_parameters_unescaped( + text, end_index, cmd.parameters) + if error is None: + try: + result.append(cmd.parse(args, context)) + except Exception as exc: # pylint:disable=broad-except + error = f'{exc}' + if error is not None: + error = ( + f'While parsing {cmd.command}{"()" if cmd.parameters else ""}' + f' at index {index + 1}{where}: {error}' + ) + if errors == 'message': + result.append(dom.ErrorPart(message=error)) + elif errors == 'exception': + raise ValueError(error) + return end_index + + def parse_string(self, text: str, context: Context, + errors: dom.ErrorType = 'message', where: str = '') -> dom.Paragraph: + result: dom.Paragraph = [] + length = len(text) + index = 0 + while index < length: + m = self._re.search(text, index) + if m is None: + result.append(dom.TextPart(text=text[index:])) + break + if m.start(1) > index: + result.append(dom.TextPart(text=text[index:m.start(1)])) + index = self._parse_command( + result, + text, + self._group_map[m.group(1)], + m.start(1), + m.end(1), + context, + errors, + where, + ) + return result + + +_CLASSIC = Parser([cmd for cmd in _COMMANDS if cmd.old_markup]) +_SEMANTIC_MARKUP = Parser(_COMMANDS) + + +def parse(text: t.Union[str, t.Sequence[str]], + context: Context, + errors: dom.ErrorType = 'message', + only_classic_markup: bool = False + ) -> t.List[dom.Paragraph]: + has_paragraphs = True + if isinstance(text, str): + has_paragraphs = False + text = [text] if text else [] + parser = _CLASSIC if only_classic_markup else _SEMANTIC_MARKUP + return [ + parser.parse_string( + par, + context, + errors=errors, + where=f' of paragraph {index + 1}' if has_paragraphs else '', + ) + for index, par in enumerate(text) + ] diff --git a/src/antsibull_docs/markup/rst.py b/src/antsibull_docs/markup/rst.py new file mode 100644 index 00000000..30d3255c --- /dev/null +++ b/src/antsibull_docs/markup/rst.py @@ -0,0 +1,121 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project +""" +ReStructured Text serialization. +""" + +import typing as t + +from . import dom +from .format import Formatter, LinkProvider, format_paragraphs as _format_paragraphs +from .html import _url_escape + + +def rst_escape(value: str, escape_ending_whitespace=False) -> str: + '''Escape RST specific constructs.''' + value = value.replace('\\', '\\\\') + value = value.replace('<', '\\<') + value = value.replace('>', '\\>') + value = value.replace('_', '\\_') + value = value.replace('*', '\\*') + value = value.replace('`', '\\`') + + if escape_ending_whitespace and value.endswith(' '): + value = value + '\\ ' + if escape_ending_whitespace and value.startswith(' '): + value = '\\ ' + value + + return value + + +class AntsibullRSTFormatter(Formatter): + @staticmethod + def _format_option_like(part: t.Union[dom.OptionNamePart, dom.ReturnValuePart], + role: str) -> str: + result: t.List[str] = [] + plugin = part.plugin + if plugin: + result.append(plugin.fqcn) + result.append('#') + result.append(plugin.type) + result.append(':') + result.append(part.name) + value = part.value + if value is not None: + result.append('=') + result.append(value) + return f'\\ :{role}:`{rst_escape("".join(result), True)}`\\ ' + + def format_error(self, part: dom.ErrorPart) -> str: + return f'\\ :strong:`ERROR while parsing`\\ : {rst_escape(part.message, True)}\\ ' + + def format_bold(self, part: dom.BoldPart) -> str: + return f'\\ :strong:`{rst_escape(part.text, True)}`\\ ' + + def format_code(self, part: dom.CodePart) -> str: + return f'\\ :literal:`{rst_escape(part.text, True)}`\\ ' + + def format_horizontal_line(self, part: dom.HorizontalLinePart) -> str: + return '\n\n.. raw:: html\n\n
\n\n' + + def format_italic(self, part: dom.ItalicPart) -> str: + return f'\\ :emphasis:`{rst_escape(part.text, True)}`\\ ' + + def format_link(self, part: dom.LinkPart) -> str: + return f'\\ `{rst_escape(part.text)} <{_url_escape(part.url)}>`__\\ ' + + def format_module(self, part: dom.ModulePart, url: t.Optional[str]) -> str: + return f'\\ :ref:`{rst_escape(part.fqcn)} `\\ ' + + def format_rst_ref(self, part: dom.RSTRefPart) -> str: + return f'\\ :ref:`{rst_escape(part.text)} <{part.ref}>`\\ ' + + def format_url(self, part: dom.URLPart) -> str: + return f'\\ {_url_escape(part.url)}\\ ' + + def format_text(self, part: dom.TextPart) -> str: + return rst_escape(part.text) + + def format_env_variable(self, part: dom.EnvVariablePart) -> str: + return f'\\ :envvar:`{rst_escape(part.name, True)}`\\ ' + + def format_option_name(self, part: dom.OptionNamePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, 'ansopt') + + def format_option_value(self, part: dom.OptionValuePart) -> str: + return f'\\ :ansval:`{rst_escape(part.value, True)}`\\ ' + + def format_plugin(self, part: dom.PluginPart, url: t.Optional[str]) -> str: + return ( + f'\\ :ref:`{rst_escape(part.plugin.fqcn)} ' + f'`\\ ' + ) + + def format_return_value(self, part: dom.ReturnValuePart, url: t.Optional[str]) -> str: + return self._format_option_like(part, 'ansretval') + + +DEFAULT_ANTSIBULL_FORMATTER = AntsibullRSTFormatter() + + +def to_rst(paragraphs: t.Sequence[dom.Paragraph], + formatter: Formatter = DEFAULT_ANTSIBULL_FORMATTER, + link_provider: t.Optional[LinkProvider] = None, + par_start: str = '', + par_end: str = '', + par_sep: str = '\n\n', + par_empty: str = r'\ ', + current_plugin: t.Optional[dom.PluginIdentifier] = None) -> str: + return _format_paragraphs( + paragraphs, + formatter=formatter, + link_provider=link_provider, + par_start=par_start, + par_end=par_end, + par_sep=par_sep, + par_empty=par_empty, + current_plugin=current_plugin, + ) diff --git a/src/antsibull_docs/markup/rstify.py b/src/antsibull_docs/markup/rstify.py new file mode 100644 index 00000000..13f6761c --- /dev/null +++ b/src/antsibull_docs/markup/rstify.py @@ -0,0 +1,43 @@ +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project +""" +rstify Jinja2 filter for use in Ansible documentation. +""" + +import typing as t + +from . import dom +from ._counter import count as _count +from .parser import parse, Context +from .rst import rst_escape as _rst_escape, to_rst + + +def rst_escape(value: t.Any, escape_ending_whitespace=False) -> str: + ''' make sure value is converted to a string, and RST special characters are escaped ''' + if not isinstance(value, str): + value = str(value) + + return _rst_escape(value, escape_ending_whitespace=escape_ending_whitespace) + + +def rst_code(value: str) -> str: + ''' Write value as :code:`...` RST construct. ''' + if not isinstance(value, str): + value = str(value) + return f':code:`{rst_escape(value, escape_ending_whitespace=True)}`' + + +def rst_ify(text: str, + *, + plugin_fqcn: t.Optional[str] = None, + plugin_type: t.Optional[str] = None) -> t.Tuple[str, t.Mapping[str, int]]: + ''' convert symbols like I(this is in italics) to valid restructured text ''' + current_plugin: t.Optional[dom.PluginIdentifier] = None + if plugin_fqcn and plugin_type: + current_plugin = dom.PluginIdentifier(fqcn=plugin_fqcn, type=plugin_type) + paragraphs = parse(text, Context(current_plugin=current_plugin), errors='message') + text = to_rst(paragraphs, current_plugin=current_plugin) + counts = _count(paragraphs) + return text, counts diff --git a/src/antsibull_docs/semantic_helper.py b/src/antsibull_docs/markup/semantic_helper.py similarity index 62% rename from src/antsibull_docs/semantic_helper.py rename to src/antsibull_docs/markup/semantic_helper.py index bdbe102f..08e9e803 100644 --- a/src/antsibull_docs/semantic_helper.py +++ b/src/antsibull_docs/markup/semantic_helper.py @@ -10,6 +10,7 @@ import re import typing as t + _ARRAY_STUB_RE = re.compile(r'\[([^\]]*)\]') _FQCN_TYPE_PREFIX_RE = re.compile(r'^([^.]+\.[^.]+\.[^#]+)#([a-z]+):(.*)$') _IGNORE_MARKER = 'ignore:' @@ -19,9 +20,9 @@ def _remove_array_stubs(text: str) -> str: return _ARRAY_STUB_RE.sub('', text) -def parse_option(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], - require_plugin=False - ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]: +def _parse(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], what: str, + require_plugin=False + ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]: """ Given the contents of O(...) / :ansopt:`...` with potential escaping removed, split it into plugin FQCN, plugin type, option link name, option name, and option value. @@ -41,45 +42,26 @@ def parse_option(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optiona plugin_type = '' text = text[len(_IGNORE_MARKER):] if ':' in text or '#' in text: - raise ValueError(f'Invalid option name "{text}"') + raise ValueError(f'Invalid {what} "{text}"') return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value -def parse_return_value(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], - require_plugin=False - ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]: +def parse_option(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + require_plugin=False + ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]: """ - Given the contents of RV(...) / :ansretval:`...` with potential escaping removed, + Given the contents of O(...) / :ansopt:`...` with potential escaping removed, split it into plugin FQCN, plugin type, option link name, option name, and option value. """ - value = None - if '=' in text: - text, value = text.split('=', 1) - m = _FQCN_TYPE_PREFIX_RE.match(text) - if m: - plugin_fqcn = m.group(1) - plugin_type = m.group(2) - text = m.group(3) - elif require_plugin: - raise ValueError('Cannot extract plugin name and type') - elif text.startswith(_IGNORE_MARKER): - plugin_fqcn = '' - plugin_type = '' - text = text[len(_IGNORE_MARKER):] - if ':' in text or '#' in text: - raise ValueError(f'Invalid return value name "{text}"') - return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value + return _parse(text, plugin_fqcn, plugin_type, 'option name', require_plugin=require_plugin) -def augment_plugin_name_type(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str] - ) -> str: +def parse_return_value(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + require_plugin=False + ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]: """ - Given the text contents of O(...) or RV(...) and a plugin's FQCN and type, insert - the FQCN and type if they are not already present. + Given the contents of RV(...) / :ansretval:`...` with potential escaping removed, + split it into plugin FQCN, plugin type, option link name, option name, and option value. """ - value = None - if '=' in text: - text, value = text.split('=', 1) - if ':' not in text and plugin_fqcn and plugin_type: - text = f'{plugin_fqcn}#{plugin_type}:{text}' - return text if value is None else f'{text}={value}' + return _parse( + text, plugin_fqcn, plugin_type, 'return value name', require_plugin=require_plugin) diff --git a/src/sphinx_antsibull_ext/roles.py b/src/sphinx_antsibull_ext/roles.py index 49c5b53b..3259a495 100644 --- a/src/sphinx_antsibull_ext/roles.py +++ b/src/sphinx_antsibull_ext/roles.py @@ -12,7 +12,7 @@ from docutils import nodes from sphinx import addnodes -from antsibull_docs.semantic_helper import parse_option, parse_return_value +from antsibull_docs.markup.semantic_helper import parse_option, parse_return_value # pylint:disable-next=unused-argument,dangerous-default-value diff --git a/test-vectors.yaml b/test-vectors.yaml new file mode 100644 index 00000000..81727c22 --- /dev/null +++ b/test-vectors.yaml @@ -0,0 +1,700 @@ +--- +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) +# SPDX-FileCopyrightText: Ansible Project +# SPDX-License-Identifier: BSD-2-Clause + +test_vectors: + empty: + source: [] + html: '' + html_plain: '' + rst: '' + md: '' + empty_content: + source: [''] + html:

+ html_plain:

+ md: ' ' + rst: '\ ' + simple: + source: |- + This is a C(test) I(module) B(markup). + html: |- +

This is a test module markup.

+ html_plain: |- +

This is a test module markup.

+ md: |- + This is a test module markup. + rst: |- + This is a \ :literal:`test`\ \ :emphasis:`module`\ \ :strong:`markup`\ . + module: + source: |- + The M(a.b.c) module. + html: |- +

The a.b.c module.

+ html_opts: + pluginLink.js: |- + (plugin_data) => `https://docs.ansible.com/ansible/devel/collections/${plugin_data.fqcn.replace(/\./g, '/')}_${plugin_data.type}.html` + pluginLink.py: |- + lambda plugin_data: f"https://docs.ansible.com/ansible/devel/collections/{plugin_data.fqcn.replace('.', '/')}_{plugin_data.type}.html" + html_plain: |- +

The a.b.c module.

+ md: |- + The [a.b.c](https\://docs.ansible.com/ansible/devel/collections/a/b/c\_module.html) module. + md_opts: + pluginLink.js: |- + (plugin_data) => `https://docs.ansible.com/ansible/devel/collections/${plugin_data.fqcn.replace(/\./g, '/')}_${plugin_data.type}.html` + pluginLink.py: |- + lambda plugin_data: f"https://docs.ansible.com/ansible/devel/collections/{plugin_data.fqcn.replace('.', '/')}_{plugin_data.type}.html" + rst: |- + The \ :ref:`a.b.c `\ module. + module_no_link: + source: |- + The M(a.b.c) module. + html: |- +

The a.b.c module.

+ html_plain: |- +

The a.b.c module.

+ md: |- + The a.b.c module. + rst: |- + The \ :ref:`a.b.c `\ module. + plugin: + source: |- + The P(a.b.c#lookup) lookup plugin. + html: |- +

The a.b.c lookup plugin.

+ html_opts: + pluginLink.js: |- + (plugin_data) => `https://docs.ansible.com/ansible/devel/collections/${plugin_data.fqcn.replace(/\./g, '/')}_${plugin_data.type}.html` + pluginLink.py: |- + lambda plugin_data: f"https://docs.ansible.com/ansible/devel/collections/{plugin_data.fqcn.replace('.', '/')}_{plugin_data.type}.html" + html_plain: |- +

The a.b.c lookup plugin.

+ md: |- + The [a.b.c](https\://docs.ansible.com/ansible/devel/collections/a/b/c\_lookup.html) lookup plugin. + md_opts: + pluginLink.js: |- + (plugin_data) => `https://docs.ansible.com/ansible/devel/collections/${plugin_data.fqcn.replace(/\./g, '/')}_${plugin_data.type}.html` + pluginLink.py: |- + lambda plugin_data: f"https://docs.ansible.com/ansible/devel/collections/{plugin_data.fqcn.replace('.', '/')}_{plugin_data.type}.html" + rst: |- + The \ :ref:`a.b.c `\ lookup plugin. + plugin_no_link: + source: |- + The P(a.b.c#lookup) lookup plugin. + html: |- +

The a.b.c lookup plugin.

+ html_plain: |- +

The a.b.c lookup plugin.

+ md: |- + The a.b.c lookup plugin. + rst: |- + The \ :ref:`a.b.c `\ lookup plugin. + link_and_url: + source: |- + An URL U(https://example.com) and L(a link, https://example.org). + html: |- +

An URL https://example.com and a link.

+ html_plain: |- +

An URL https://example.com and a link.

+ md: |- + An URL [https\://example.com](https\://example.com) and [a link](https\://example.org). + rst: |- + An URL \ https://example.com\ and \ `a link `__\ . + rst_ref: + source: |- + A R(RST reference, ansible_collections.community.general.ufw_module). + html: |- +

A RST reference.

+ html_plain: |- +

A RST reference.

+ md: |- + A RST reference. + rst: |- + A \ :ref:`RST reference `\ . + horizontal_line: + source: |- + foo HORIZONTALLINE bar + html: |- +

foo


bar

+ html_plain: |- +

foo


bar

+ md: |- + foo
bar + rst: |- + foo + + .. raw:: html + +
+ + bar + semantic_markup: + source: |- + foo E(FOOBAR) bar P(foo.bar.baz#bam) has value V( foo\),bar\\bam ). + html: |- +

foo FOOBAR bar foo.bar.baz has value foo),bar\bam .

+ html_plain: |- +

foo FOOBAR bar foo.bar.baz has value foo),bar\bam .

+ md: |- + foo FOOBAR bar foo.bar.baz has value foo\)\,bar\\bam . + rst: |- + foo \ :envvar:`FOOBAR`\ bar \ :ref:`foo.bar.baz `\ has value \ :ansval:`\ foo),bar\\bam \ `\ . + option_name_no_current_plugin: + source: + - |- + O(foo) O(bar.baz[123].bam[len(x\) - 1]) + - |- + O(foo=) O(bar.baz[123].bam[len(x\) - 1]=) + - |- + O(foo=bar) O(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(bam.baz.foo#lookup:foo) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + O(bam.baz.foo#lookup:foo=) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(bam.baz.foo#lookup:foo=bar) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(ignore:foo) O(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + O(ignore:foo=) O(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(ignore:foo=bar) O(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + rst: |- + \ :ansopt:`foo`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo=`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo=bar`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`bam.baz.foo#lookup:foo`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=bar`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`foo`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo=`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo=bar`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=bar`\ + option_name_current_plugin: + source: + - |- + O(foo) O(bar.baz[123].bam[len(x\) - 1]) + - |- + O(foo=) O(bar.baz[123].bam[len(x\) - 1]=) + - |- + O(foo=bar) O(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(bam.baz.foo#lookup:foo) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + O(bam.baz.foo#lookup:foo=) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(bam.baz.foo#lookup:foo=bar) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(ignore:foo) O(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + O(ignore:foo=) O(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(ignore:foo=bar) O(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + rst: |- + \ :ansopt:`foo.bar.baz.bam#boo:foo`\ \ :ansopt:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo.bar.baz.bam#boo:foo=`\ \ :ansopt:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo.bar.baz.bam#boo:foo=bar`\ \ :ansopt:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`bam.baz.foo#lookup:foo`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=bar`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`foo`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo=`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo=bar`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=bar`\ + parse_opts: + current_plugin: + fqcn: foo.bar.baz.bam + type: boo + option_name_no_current_plugin_w_links: + source: + - |- + O(foo) O(bar.baz[123].bam[len(x\) - 1]) + - |- + O(foo=) O(bar.baz[123].bam[len(x\) - 1]=) + - |- + O(foo=bar) O(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(bam.baz.foo#lookup:foo) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + O(bam.baz.foo#lookup:foo=) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(bam.baz.foo#lookup:foo=bar) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(ignore:foo) O(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + O(ignore:foo=) O(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(ignore:foo=bar) O(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + md_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + rst: |- + \ :ansopt:`foo`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo=`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo=bar`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`bam.baz.foo#lookup:foo`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=bar`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`foo`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo=`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo=bar`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=bar`\ + option_name_current_plugin_w_links: + source: + - |- + O(foo) O(bar.baz[123].bam[len(x\) - 1]) + - |- + O(foo=) O(bar.baz[123].bam[len(x\) - 1]=) + - |- + O(foo=bar) O(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(bam.baz.foo#lookup:foo) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + O(bam.baz.foo#lookup:foo=) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(bam.baz.foo#lookup:foo=bar) O(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + O(ignore:foo) O(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + O(ignore:foo=) O(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + O(ignore:foo=bar) O(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + md_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + rst: |- + \ :ansopt:`foo.bar.baz.bam#boo:foo`\ \ :ansopt:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo.bar.baz.bam#boo:foo=`\ \ :ansopt:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo.bar.baz.bam#boo:foo=bar`\ \ :ansopt:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`bam.baz.foo#lookup:foo`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`bam.baz.foo#lookup:foo=bar`\ \ :ansopt:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansopt:`foo`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansopt:`foo=`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansopt:`foo=bar`\ \ :ansopt:`bar.baz[123].bam[len(x) - 1]=bar`\ + parse_opts: + current_plugin: + fqcn: foo.bar.baz.bam + type: boo + return_value_no_current_plugin: + source: + - |- + RV(foo) RV(bar.baz[123].bam[len(x\) - 1]) + - |- + RV(foo=) RV(bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(foo=bar) RV(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(bam.baz.foo#lookup:foo) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(bam.baz.foo#lookup:foo=) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(bam.baz.foo#lookup:foo=bar) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(ignore:foo) RV(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(ignore:foo=) RV(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(ignore:foo=bar) RV(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + rst: |- + \ :ansretval:`foo`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo=`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo=bar`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`bam.baz.foo#lookup:foo`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=bar`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`foo`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo=`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo=bar`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=bar`\ + return_value_current_plugin: + source: + - |- + RV(foo) RV(bar.baz[123].bam[len(x\) - 1]) + - |- + RV(foo=) RV(bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(foo=bar) RV(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(bam.baz.foo#lookup:foo) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(bam.baz.foo#lookup:foo=) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(bam.baz.foo#lookup:foo=bar) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(ignore:foo) RV(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(ignore:foo=) RV(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(ignore:foo=bar) RV(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + rst: |- + \ :ansretval:`foo.bar.baz.bam#boo:foo`\ \ :ansretval:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo.bar.baz.bam#boo:foo=`\ \ :ansretval:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo.bar.baz.bam#boo:foo=bar`\ \ :ansretval:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`bam.baz.foo#lookup:foo`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=bar`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`foo`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo=`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo=bar`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=bar`\ + parse_opts: + current_plugin: + fqcn: foo.bar.baz.bam + type: boo + return_value_no_current_plugin_w_links: + source: + - |- + RV(foo) RV(bar.baz[123].bam[len(x\) - 1]) + - |- + RV(foo=) RV(bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(foo=bar) RV(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(bam.baz.foo#lookup:foo) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(bam.baz.foo#lookup:foo=) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(bam.baz.foo#lookup:foo=bar) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(ignore:foo) RV(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(ignore:foo=) RV(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(ignore:foo=bar) RV(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + md_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + rst: |- + \ :ansretval:`foo`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo=`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo=bar`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`bam.baz.foo#lookup:foo`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=bar`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`foo`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo=`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo=bar`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=bar`\ + return_value_current_plugin_w_links: + source: + - |- + RV(foo) RV(bar.baz[123].bam[len(x\) - 1]) + - |- + RV(foo=) RV(bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(foo=bar) RV(bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(bam.baz.foo#lookup:foo) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(bam.baz.foo#lookup:foo=) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(bam.baz.foo#lookup:foo=bar) RV(bam.baz.foo#lookup:bar.baz[123].bam[len(x\) - 1]=bar) + - |- + RV(ignore:foo) RV(ignore:bar.baz[123].bam[len(x\) - 1]) + - |- + RV(ignore:foo=) RV(ignore:bar.baz[123].bam[len(x\) - 1]=) + - |- + RV(ignore:foo=bar) RV(ignore:bar.baz[123].bam[len(x\) - 1]=bar) + html: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ html_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + html_plain: |- +

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

foo bar.baz[123].bam[len(x) - 1]

foo= bar.baz[123].bam[len(x) - 1]=

foo=bar bar.baz[123].bam[len(x) - 1]=bar

+ md: |- + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + + foo bar.baz\[123\].bam\[len\(x\) \- 1\] + + foo\= bar.baz\[123\].bam\[len\(x\) \- 1\]\= + + foo\=bar bar.baz\[123\].bam\[len\(x\) \- 1\]\=bar + md_opts: + pluginOptionLikeLink.js: |- + (plugin, what, name, current_plugin) => `https://docs.ansible.com/ansible/devel/collections/${plugin.fqcn.replace(/\./g, '/')}_${plugin.type}.html#${what}-${name.join('/')}` + pluginOptionLikeLink.py: |- + lambda plugin, what, name, current_plugin: f"https://docs.ansible.com/ansible/devel/collections/{plugin.fqcn.replace('.', '/')}_{plugin.type}.html#{what}-{'/'.join(name)}" + rst: |- + \ :ansretval:`foo.bar.baz.bam#boo:foo`\ \ :ansretval:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo.bar.baz.bam#boo:foo=`\ \ :ansretval:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo.bar.baz.bam#boo:foo=bar`\ \ :ansretval:`foo.bar.baz.bam#boo:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`bam.baz.foo#lookup:foo`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`bam.baz.foo#lookup:foo=bar`\ \ :ansretval:`bam.baz.foo#lookup:bar.baz[123].bam[len(x) - 1]=bar`\ + + \ :ansretval:`foo`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]`\ + + \ :ansretval:`foo=`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=`\ + + \ :ansretval:`foo=bar`\ \ :ansretval:`bar.baz[123].bam[len(x) - 1]=bar`\ + parse_opts: + current_plugin: + fqcn: foo.bar.baz.bam + type: boo + errors: + source: + - P(foo) + - C(foo + - R(foo,bar + html: |- +

ERROR while parsing: While parsing P() at index 1 of paragraph 1: Parameter "foo" is not of the form FQCN#type

ERROR while parsing: While parsing C() at index 1 of paragraph 2: Cannot find closing ")" after last parameter

ERROR while parsing: While parsing R() at index 1 of paragraph 3: Cannot find closing ")" after last parameter

+ html_plain: |- +

ERROR while parsing: While parsing P() at index 1 of paragraph 1: Parameter "foo" is not of the form FQCN#type

ERROR while parsing: While parsing C() at index 1 of paragraph 2: Cannot find closing ")" after last parameter

ERROR while parsing: While parsing R() at index 1 of paragraph 3: Cannot find closing ")" after last parameter

+ md: |- + ERROR while parsing: While parsing P\(\) at index 1 of paragraph 1\: Parameter \"foo\" is not of the form FQCN\#type + + ERROR while parsing: While parsing C\(\) at index 1 of paragraph 2\: Cannot find closing \"\)\" after last parameter + + ERROR while parsing: While parsing R\(\) at index 1 of paragraph 3\: Cannot find closing \"\)\" after last parameter + rst: |- + \ :strong:`ERROR while parsing`\ : While parsing P() at index 1 of paragraph 1: Parameter "foo" is not of the form FQCN#type\ + + \ :strong:`ERROR while parsing`\ : While parsing C() at index 1 of paragraph 2: Cannot find closing ")" after last parameter\ + + \ :strong:`ERROR while parsing`\ : While parsing R() at index 1 of paragraph 3: Cannot find closing ")" after last parameter\ diff --git a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo2_module.rst b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo2_module.rst index 4cdf2b96..f8fbff78 100644 --- a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo2_module.rst +++ b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo2_module.rst @@ -323,8 +323,8 @@ Common return values are documented :ref:`here `, the foll

Some bar.

-

Referencing myself as bar.

-

Do not confuse with bar.

+

Referencing myself as bar.

+

Do not confuse with bar.

Returned: success

Sample: "baz"

diff --git a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst index cd2df779..becf20af 100644 --- a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst +++ b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_become.rst @@ -109,8 +109,8 @@ Parameters

Bar. BAR!

-

Totally unrelated to become_user. Even with become_user=foo.

-

Might not be compatible when become_user is bar, though.

+

Totally unrelated to become_user. Even with become_user=foo.

+

Might not be compatible when become_user is bar, though.

diff --git a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst index 7fae7e20..cabbfb21 100644 --- a/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst +++ b/tests/functional/baseline-use-html-blobs/collections/ns2/col/foo_module.rst @@ -120,8 +120,8 @@ Parameters

A bar.

-

Independent from foo.

-

Do not confuse with bar.

+

Independent from foo.

+

Do not confuse with bar.

@@ -165,7 +165,7 @@ Parameters

A sub foo.

Whatever.

-

Also required when subfoo is specified when foo=bar or baz.

+

Also required when subfoo is specified when foo=bar or baz.

@@ -428,8 +428,8 @@ Common return values are documented :ref:`here `, the foll

Some bar.

-

Referencing myself as bar.

-

Do not confuse with bar.

+

Referencing myself as bar.

+

Do not confuse with bar.

Returned: success

Sample: "baz"

diff --git a/tests/units/markup/test_counter.py b/tests/units/markup/test_counter.py new file mode 100644 index 00000000..2df1a156 --- /dev/null +++ b/tests/units/markup/test_counter.py @@ -0,0 +1,122 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project + +import typing as t + +import pytest + +from antsibull_docs.markup import dom +from antsibull_docs.markup._counter import count + + +TEST_COUNTER = [ + ( + [], + { + 'italic': 0, + 'bold': 0, + 'module': 0, + 'plugin': 0, + 'link': 0, + 'url': 0, + 'ref': 0, + 'const': 0, + 'option-name': 0, + 'option-value': 0, + 'environment-var': 0, + 'return-value': 0, + 'ruler': 0, + }, + ), + ( + [dom.ErrorPart(message='foo')], + { + 'italic': 0, + 'bold': 0, + 'module': 0, + 'plugin': 0, + 'link': 0, + 'url': 0, + 'ref': 0, + 'const': 0, + 'option-name': 0, + 'option-value': 0, + 'environment-var': 0, + 'return-value': 0, + 'ruler': 0, + }, + ), + ( + [ + dom.TextPart(text='foo '), + dom.ItalicPart(text='bar'), + dom.TextPart(text=' baz '), + dom.CodePart(text=' bam '), + dom.TextPart(text=' '), + dom.BoldPart(text=' ( boo '), + dom.TextPart(text=' ) '), + dom.URLPart(url='https://example.com/?foo=bar'), + dom.HorizontalLinePart(), + dom.TextPart(text=' '), + dom.LinkPart(text='foo', url='https://bar.com'), + dom.TextPart(text=' '), + dom.RSTRefPart(text=' a', ref='b '), + dom.ModulePart(fqcn='foo.bar.baz'), + dom.TextPart(text='HORIZONTALLINEx '), + dom.ModulePart(fqcn='foo.bar.baz.bam'), + ], + { + 'italic': 1, + 'bold': 1, + 'module': 2, + 'plugin': 0, + 'link': 1, + 'url': 1, + 'ref': 1, + 'const': 1, + 'option-name': 0, + 'option-value': 0, + 'environment-var': 0, + 'return-value': 0, + 'ruler': 1, + }, + ), + ( + [ + dom.TextPart(text='foo '), + dom.EnvVariablePart(name='a),b'), + dom.TextPart(text=' '), + dom.PluginPart(plugin=dom.PluginIdentifier(fqcn='foo.bar.baz', type='bam')), + dom.TextPart(text=' baz '), + dom.OptionValuePart(value=' b,na)\\m, '), + dom.TextPart(text=' '), + dom.OptionNamePart(plugin=None, link=['foo'], name='foo', value=None), + dom.TextPart(text=' '), + dom.ReturnValuePart(plugin=None, link=['bar', 'baz'], name='bar.baz[1]', value=None), + ], + { + 'italic': 0, + 'bold': 0, + 'module': 0, + 'plugin': 1, + 'link': 0, + 'url': 0, + 'ref': 0, + 'const': 0, + 'option-name': 1, + 'option-value': 1, + 'environment-var': 1, + 'return-value': 1, + 'ruler': 0, + }, + ), +] + + +@pytest.mark.parametrize('paragraph, counter', TEST_COUNTER) +def test_count(paragraph: dom.Paragraph, counter: t.Dict[str, int]) -> None: + result = count([paragraph]) + assert result == counter diff --git a/tests/units/markup/test_dom.py b/tests/units/markup/test_dom.py new file mode 100644 index 00000000..c25a3394 --- /dev/null +++ b/tests/units/markup/test_dom.py @@ -0,0 +1,134 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project + +import typing as t + +import pytest + +from antsibull_docs.markup import dom + + +class _TestWalker(dom.Walker): + result: t.List[dom.AnyPart] + + def __init__(self): + self.result = [] + + def process_error(self, part: dom.ErrorPart) -> None: + assert part.type == dom.PartType.ERROR + self.result.append(part) + + def process_bold(self, part: dom.BoldPart) -> None: + assert part.type == dom.PartType.BOLD + self.result.append(part) + + def process_code(self, part: dom.CodePart) -> None: + assert part.type == dom.PartType.CODE + self.result.append(part) + + def process_horizontal_line(self, part: dom.HorizontalLinePart) -> None: + assert part.type == dom.PartType.HORIZONTAL_LINE + self.result.append(part) + + def process_italic(self, part: dom.ItalicPart) -> None: + assert part.type == dom.PartType.ITALIC + self.result.append(part) + + def process_link(self, part: dom.LinkPart) -> None: + assert part.type == dom.PartType.LINK + self.result.append(part) + + def process_module(self, part: dom.ModulePart) -> None: + assert part.type == dom.PartType.MODULE + self.result.append(part) + + def process_rst_ref(self, part: dom.RSTRefPart) -> None: + assert part.type == dom.PartType.RST_REF + self.result.append(part) + + def process_url(self, part: dom.URLPart) -> None: + assert part.type == dom.PartType.URL + self.result.append(part) + + def process_text(self, part: dom.TextPart) -> None: + assert part.type == dom.PartType.TEXT + self.result.append(part) + + def process_env_variable(self, part: dom.EnvVariablePart) -> None: + assert part.type == dom.PartType.ENV_VARIABLE + self.result.append(part) + + def process_option_name(self, part: dom.OptionNamePart) -> None: + assert part.type == dom.PartType.OPTION_NAME + self.result.append(part) + + def process_option_value(self, part: dom.OptionValuePart) -> None: + assert part.type == dom.PartType.OPTION_VALUE + self.result.append(part) + + def process_plugin(self, part: dom.PluginPart) -> None: + assert part.type == dom.PartType.PLUGIN + self.result.append(part) + + def process_return_value(self, part: dom.ReturnValuePart) -> None: + assert part.type == dom.PartType.RETURN_VALUE + self.result.append(part) + + +TEST_WALKER = [ + [], + [dom.ErrorPart(message='foo')], + [ + dom.TextPart(text='foo '), + dom.ItalicPart(text='bar'), + dom.TextPart(text=' baz '), + dom.CodePart(text=' bam '), + dom.TextPart(text=' '), + dom.BoldPart(text=' ( boo '), + dom.TextPart(text=' ) '), + dom.URLPart(url='https://example.com/?foo=bar'), + dom.HorizontalLinePart(), + dom.TextPart(text=' '), + dom.LinkPart(text='foo', url='https://bar.com'), + dom.TextPart(text=' '), + dom.RSTRefPart(text=' a', ref='b '), + dom.ModulePart(fqcn='foo.bar.baz'), + dom.TextPart(text='HORIZONTALLINEx '), + dom.ModulePart(fqcn='foo.bar.baz.bam'), + ], + [ + dom.TextPart(text='foo '), + dom.EnvVariablePart(name='a),b'), + dom.TextPart(text=' '), + dom.PluginPart(plugin=dom.PluginIdentifier(fqcn='foo.bar.baz', type='bam')), + dom.TextPart(text=' baz '), + dom.OptionValuePart(value=' b,na)\\m, '), + dom.TextPart(text=' '), + dom.OptionNamePart(plugin=None, link=['foo'], name='foo', value=None), + dom.TextPart(text=' '), + dom.ReturnValuePart(plugin=None, link=['bar', 'baz'], name='bar.baz[1]', value=None), + ], +] + + +@pytest.mark.parametrize('data', TEST_WALKER) +def test_walk(data: dom.Paragraph) -> None: + walker = _TestWalker() + dom.walk(data, walker) + assert walker.result == data + + # The following has no side-effect (except increasing line coverage) + walker = dom.NoopWalker() + dom.walk(data, walker) + + +def test_internal_error() -> None: + class FakePart(t.NamedTuple): + type: int = 23 + + with pytest.raises(RuntimeError) as exc: + dom.walk([FakePart()], dom.NoopWalker()) + assert str(exc.value) == 'Internal error: unknown type 23' diff --git a/tests/units/markup/test_format.py b/tests/units/markup/test_format.py new file mode 100644 index 00000000..d2db7e33 --- /dev/null +++ b/tests/units/markup/test_format.py @@ -0,0 +1,65 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project + +import typing as t + +from antsibull_docs.markup import dom +from antsibull_docs.markup.format import Formatter, format_paragraphs + + +class _TestFormatter(Formatter): + def format_error(self, part: dom.ErrorPart) -> str: + return 'format_error' + + def format_bold(self, part: dom.BoldPart) -> str: + return 'format_bold' + + def format_code(self, part: dom.CodePart) -> str: + return 'format_code' + + def format_horizontal_line(self, part: dom.HorizontalLinePart) -> str: + return 'format_horizontal_line' + + def format_italic(self, part: dom.ItalicPart) -> str: + return 'format_italic' + + def format_link(self, part: dom.LinkPart) -> str: + return 'format_link' + + def format_module(self, part: dom.ModulePart, url: t.Optional[str]) -> str: + return 'format_module' + + def format_rst_ref(self, part: dom.RSTRefPart) -> str: + return 'format_rst_ref' + + def format_url(self, part: dom.URLPart) -> str: + return 'format_url' + + def format_text(self, part: dom.TextPart) -> str: + return 'format_text' + + def format_env_variable(self, part: dom.EnvVariablePart) -> str: + return 'format_env_variable' + + def format_option_name(self, part: dom.OptionNamePart, url: t.Optional[str]) -> str: + return 'format_option_name' + + def format_option_value(self, part: dom.OptionValuePart) -> str: + return 'format_option_value' + + def format_plugin(self, part: dom.PluginPart, url: t.Optional[str]) -> str: + return 'format_plugin' + + def format_return_value(self, part: dom.ReturnValuePart, url: t.Optional[str]) -> str: + return 'format_return_value' + + +def test_format_paragraphs(): + assert format_paragraphs([], formatter=_TestFormatter()) == '' + assert format_paragraphs( + [[dom.HorizontalLinePart(), dom.TextPart(text='foo')]], + formatter=_TestFormatter() + ) == 'format_horizontal_lineformat_text' diff --git a/tests/units/markup/test_html.py b/tests/units/markup/test_html.py new file mode 100644 index 00000000..de1f8f60 --- /dev/null +++ b/tests/units/markup/test_html.py @@ -0,0 +1,26 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project + +from antsibull_docs.markup import dom +from antsibull_docs.markup.html import html_escape, to_html, to_html_plain + + +def test_html_escape(): + assert html_escape('') == '' + assert html_escape(' foo ') == ' foo ' + assert html_escape('<&>') == '<a href="a&b">&lt;&amp;&gt;</a>' + +def test_to_html(): + assert to_html([]) == '' + assert to_html([[dom.TextPart(text='test')]]) == '

test

' + assert to_html([[dom.TextPart(text='test')]], par_start='
', par_end='
') == '
test
' + assert to_html([[dom.CodePart(text='test')]]) == "

test

" + +def test_to_html_plain(): + assert to_html_plain([]) == '' + assert to_html_plain([[dom.TextPart(text='test')]]) == '

test

' + assert to_html_plain([[dom.TextPart(text='test')]], par_start='
', par_end='
') == '
test
' + assert to_html_plain([[dom.CodePart(text='test')]]) == '

test

' diff --git a/tests/units/markup/test_markup.py b/tests/units/markup/test_markup.py new file mode 100644 index 00000000..8bfffc8e --- /dev/null +++ b/tests/units/markup/test_markup.py @@ -0,0 +1,58 @@ +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2020, Ansible Project + +import pytest + +from antsibull_docs.markup.rstify import rst_escape, rst_ify + +RST_IFY_DATA = { + # No substitutions + 'no-op': 'no-op', + 'no-op Z(test)': 'no-op Z(test)', + # Simple cases of all substitutions + 'I(italic)': r'\ :emphasis:`italic`\ ', + 'B(bold)': r'\ :strong:`bold`\ ', + 'M(ansible.builtin.yum)': r'\ :ref:`ansible.builtin.yum' + r' `\ ', + 'U(https://docs.ansible.com)': r'\ https://docs.ansible.com\ ', + 'L(the user guide,https://docs.ansible.com/user-guide.html)': r'\ `the user guide' + r' `__\ ', + 'R(the user guide,user-guide)': r'\ :ref:`the user guide `\ ', + 'C(/usr/bin/file)': r'\ :literal:`/usr/bin/file`\ ', + 'HORIZONTALLINE': '\n\n.. raw:: html\n\n
\n\n', + # Multiple substitutions + 'The M(ansible.builtin.yum) module B(MUST) be given the C(package) parameter. See the R(looping docs,using-loops) for more info': + r'The \ :ref:`ansible.builtin.yum `\ module \ :strong:`MUST`\ be given the \ :literal:`package`\ parameter. See the \ :ref:`looping docs `\ for more info', + # Problem cases + 'IBM(International Business Machines)': 'IBM(International Business Machines)', + 'L(the user guide, https://docs.ansible.com/)': r'\ `the user guide `__\ ', + 'R(the user guide, user-guide)': r'\ :ref:`the user guide `\ ', +} + + +@pytest.mark.parametrize('text, expected', RST_IFY_DATA.items()) +def test_rst_ify(text, expected): + assert rst_ify(text, plugin_fqcn='foo.bar.baz', plugin_type='module')[0] == expected + + +RST_ESCAPE_DATA = { + '': '', + 'no-op': 'no-op', + None: 'None', + 1: '1', + '*': '\\*', + '_': '\\_', + '<': '\\<', + '>': '\\>', + '`': '\\`', + '\\': '\\\\', + '\\*': '\\\\\\*', + '*\\': '\\*\\\\', + ':role:`test`': ':role:\\`test\\`', +} + + +@pytest.mark.parametrize('value, expected', RST_ESCAPE_DATA.items()) +def test_escape_ify(value, expected): + assert rst_escape(value) == expected diff --git a/tests/units/markup/test_md.py b/tests/units/markup/test_md.py new file mode 100644 index 00000000..e023cbc3 --- /dev/null +++ b/tests/units/markup/test_md.py @@ -0,0 +1,18 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project + +from antsibull_docs.markup import dom +from antsibull_docs.markup.md import md_escape, to_md + + +def test_md_escape(): + assert md_escape('') == '' + assert md_escape(' foo ') == ' foo ' + assert md_escape(r'[]!.()-\@<>?[]!.()-\@<>?') == r'\[\]\!.\(\)\-\\\@\<\>\?\[\]\!.\(\)\-\\\@\<\>\?' + +def test_to_rst(): + assert to_md([]) == '' + assert to_md([[dom.TextPart(text='test')]]) == 'test' diff --git a/tests/units/markup/test_parser.py b/tests/units/markup/test_parser.py new file mode 100644 index 00000000..23b4ac41 --- /dev/null +++ b/tests/units/markup/test_parser.py @@ -0,0 +1,364 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project + +import typing as t + +import pytest + +from antsibull_docs.markup import dom +from antsibull_docs.markup.parser import parse, CommandParser, Context, Parser + + +TEST_PARSE_DATA: t.List[t.Tuple[t.Union[str, t.List[str]], Context, t.Dict[str, t.Any], t.List[dom.Paragraph]]] = [ + ('', Context(), {}, []), + ([''], Context(), {}, [[]]), + ('test', Context(), {}, [[dom.TextPart(text='test')]]), + ('test', Context(), {}, [[dom.TextPart(text='test')]]), + # classic markup: + ( + 'foo I(bar) baz C( bam ) B( ( boo ) ) U(https://example.com/?foo=bar)HORIZONTALLINE L(foo , https://bar.com) R( a , b )M(foo.bar.baz)HORIZONTALLINEx M(foo.bar.baz.bam)', + Context(), + {}, + [ + [ + dom.TextPart(text='foo '), + dom.ItalicPart(text='bar'), + dom.TextPart(text=' baz '), + dom.CodePart(text=' bam '), + dom.TextPart(text=' '), + dom.BoldPart(text=' ( boo '), + dom.TextPart(text=' ) '), + dom.URLPart(url='https://example.com/?foo=bar'), + dom.HorizontalLinePart(), + dom.TextPart(text=' '), + dom.LinkPart(text='foo', url='https://bar.com'), + dom.TextPart(text=' '), + dom.RSTRefPart(text=' a', ref='b '), + dom.ModulePart(fqcn='foo.bar.baz'), + dom.TextPart(text='HORIZONTALLINEx '), + dom.ModulePart(fqcn='foo.bar.baz.bam'), + ], + ], + ), + ( + 'foo I(bar) baz C( bam ) B( ( boo ) ) U(https://example.com/?foo=bar)HORIZONTALLINE L(foo , https://bar.com) R( a , b )M(foo.bar.baz)HORIZONTALLINEx M(foo.bar.baz.bam)', + Context(), + dict(only_classic_markup=True), + [ + [ + dom.TextPart(text='foo '), + dom.ItalicPart(text='bar'), + dom.TextPart(text=' baz '), + dom.CodePart(text=' bam '), + dom.TextPart(text=' '), + dom.BoldPart(text=' ( boo '), + dom.TextPart(text=' ) '), + dom.URLPart(url='https://example.com/?foo=bar'), + dom.HorizontalLinePart(), + dom.TextPart(text=' '), + dom.LinkPart(text='foo', url='https://bar.com'), + dom.TextPart(text=' '), + dom.RSTRefPart(text=' a', ref='b '), + dom.ModulePart(fqcn='foo.bar.baz'), + dom.TextPart(text='HORIZONTALLINEx '), + dom.ModulePart(fqcn='foo.bar.baz.bam'), + ], + ], + ), + # semantic markup: + ( + 'foo E(a\\),b) P(foo.bar.baz#bam) baz V( b\\,\\na\\)\\\\m\\, ) O(foo) ', + Context(), + {}, + [ + [ + dom.TextPart(text='foo '), + dom.EnvVariablePart(name='a),b'), + dom.TextPart(text=' '), + dom.PluginPart(plugin=dom.PluginIdentifier(fqcn='foo.bar.baz', type='bam')), + dom.TextPart(text=' baz '), + dom.OptionValuePart(value=' b,na)\\m, '), + dom.TextPart(text=' '), + dom.OptionNamePart(plugin=None, link=['foo'], name='foo', value=None), + dom.TextPart(text=' '), + ], + ], + ), + # semantic markup option name: + ('O(foo)', Context(), {}, [ + [ + dom.OptionNamePart(plugin=None, link=['foo'], name='foo', value=None), + ], + ]), + ('O(ignore:foo)', Context(current_plugin=dom.PluginIdentifier('foo.bar.baz', type='bam')), {}, [ + [ + dom.OptionNamePart(plugin=None, link=['foo'], name='foo', value=None), + ], + ]), + ('O(foo)', Context(current_plugin=dom.PluginIdentifier('foo.bar.baz', type='bam')), {}, [ + [ + dom.OptionNamePart(plugin=dom.PluginIdentifier('foo.bar.baz', type='bam'), link=['foo'], name='foo', value=None), + ], + ]), + ('O(foo.bar.baz#bam:foo)', Context(), {}, [ + [ + dom.OptionNamePart(plugin=dom.PluginIdentifier('foo.bar.baz', type='bam'), link=['foo'], name='foo', value=None), + ], + ]), + ('O(foo=bar)', Context(), {}, [ + [ + dom.OptionNamePart(plugin=None, link=['foo'], name='foo', value='bar'), + ], + ]), + ('O(foo.baz=bam)', Context(), {}, [ + [ + dom.OptionNamePart(plugin=None, link=['foo', 'baz'], name='foo.baz', value='bam'), + ], + ]), + ('O(foo[1].baz[bam.bar.boing].boo)', Context(), {}, [ + [ + dom.OptionNamePart(plugin=None, link=['foo', 'baz', 'boo'], name='foo[1].baz[bam.bar.boing].boo', value=None), + ], + ]), + ('O(bar.baz.bam.boo#lookup:foo[1].baz[bam.bar.boing].boo)', Context(), {}, [ + [ + dom.OptionNamePart(plugin=dom.PluginIdentifier('bar.baz.bam.boo', type='lookup'), link=['foo', 'baz', 'boo'], name='foo[1].baz[bam.bar.boing].boo', value=None), + ], + ]), + # semantic markup return value name: + ('RV(foo)', Context(), {}, [ + [ + dom.ReturnValuePart(plugin=None, link=['foo'], name='foo', value=None), + ], + ]), + ('RV(ignore:foo)', Context(current_plugin=dom.PluginIdentifier('foo.bar.baz', type='bam')), {}, [ + [ + dom.ReturnValuePart(plugin=None, link=['foo'], name='foo', value=None), + ], + ]), + ('RV(foo)', Context(current_plugin=dom.PluginIdentifier('foo.bar.baz', type='bam')), {}, [ + [ + dom.ReturnValuePart(plugin=dom.PluginIdentifier('foo.bar.baz', type='bam'), link=['foo'], name='foo', value=None), + ], + ]), + ('RV(foo.bar.baz#bam:foo)', Context(), {}, [ + [ + dom.ReturnValuePart(plugin=dom.PluginIdentifier('foo.bar.baz', type='bam'), link=['foo'], name='foo', value=None), + ], + ]), + ('RV(foo=bar)', Context(), {}, [ + [ + dom.ReturnValuePart(plugin=None, link=['foo'], name='foo', value='bar'), + ], + ]), + ('RV(foo.baz=bam)', Context(), {}, [ + [ + dom.ReturnValuePart(plugin=None, link=['foo', 'baz'], name='foo.baz', value='bam'), + ], + ]), + ('RV(foo[1].baz[bam.bar.boing].boo)', Context(), {}, [ + [ + dom.ReturnValuePart(plugin=None, link=['foo', 'baz', 'boo'], name='foo[1].baz[bam.bar.boing].boo', value=None), + ], + ]), + ('RV(bar.baz.bam.boo#lookup:foo[1].baz[bam.bar.boing].boo)', Context(), {}, [ + [ + dom.ReturnValuePart(plugin=dom.PluginIdentifier('bar.baz.bam.boo', type='lookup'), link=['foo', 'baz', 'boo'], name='foo[1].baz[bam.bar.boing].boo', value=None), + ], + ]), + # bad parameter parsing (no escaping, error message): + ('M(', Context(), {}, [ + [dom.ErrorPart(message='While parsing M() at index 1: Cannot find closing ")" after last parameter')], + ]), + ('M(foo', Context(), dict(errors='message'), [ + [dom.ErrorPart(message='While parsing M() at index 1: Cannot find closing ")" after last parameter')], + ]), + ('L(foo)', Context(), dict(errors='message'), [ + [ + dom.ErrorPart(message='While parsing L() at index 1: Cannot find comma separating parameter 1 from the next one'), + ], + ]), + ('L(foo,bar', Context(), dict(errors='message'), [ + [dom.ErrorPart(message='While parsing L() at index 1: Cannot find closing ")" after last parameter')], + ]), + ('L(foo), bar', Context(), dict(errors='message'), [ + [dom.ErrorPart(message='While parsing L() at index 1: Cannot find closing ")" after last parameter')], + ]), + ('P(', Context(), {}, [ + [dom.ErrorPart(message='While parsing P() at index 1: Cannot find closing ")" after last parameter')], + ]), + ('P(foo', Context(), dict(errors='message'), [ + [dom.ErrorPart(message='While parsing P() at index 1: Cannot find closing ")" after last parameter')], + ]), + # bad module ref (error message): + ('M(foo)', Context(), {}, [ + [dom.ErrorPart(message='While parsing M() at index 1: Module name "foo" is not a FQCN')], + ]), + (' M(foo.bar)', Context(), dict(errors='message'), [ + [ + dom.TextPart(text=' '), + dom.ErrorPart(message='While parsing M() at index 2: Module name "foo.bar" is not a FQCN'), + ], + ]), + (' M(foo. bar.baz)', Context(), dict(errors='message'), [ + [ + dom.TextPart(text=' '), + dom.ErrorPart(message='While parsing M() at index 3: Module name "foo. bar.baz" is not a FQCN'), + ], + ]), + (' M(foo) baz', Context(), dict(errors='message'), [ + [ + dom.TextPart(text=' '), + dom.ErrorPart(message='While parsing M() at index 4: Module name "foo" is not a FQCN'), + dom.TextPart(text=' baz'), + ], + ]), + # bad plugin ref (error message): + ('P(foo)', Context(), {}, [ + [ + dom.ErrorPart(message='While parsing P() at index 1: Parameter "foo" is not of the form FQCN#type'), + ], + ]), + ('P(f o.b r.b z#bar)', Context(), dict(errors='message'), [ + [ + dom.ErrorPart(message='While parsing P() at index 1: Plugin name "f o.b r.b z" is not a FQCN'), + ], + ]), + ('P(foo.bar.baz#b m)', Context(), dict(errors='message'), [ + [ + dom.ErrorPart(message='While parsing P() at index 1: Plugin type "b m" is not valid'), + ], + ]), + # bad option name/return value (error message): + ('O(f o.b r.b z#bam:foobar)', Context(), {}, [ + [ + dom.ErrorPart(message='While parsing O() at index 1: Plugin name "f o.b r.b z" is not a FQCN'), + ], + ]), + ('O(foo.bar.baz#b m:foobar)', Context(), dict(errors='message'), [ + [ + dom.ErrorPart(message='While parsing O() at index 1: Plugin type "b m" is not valid'), + ], + ]), + ('O(foo:bar:baz)', Context(), dict(errors='message'), [ + [ + dom.ErrorPart(message='While parsing O() at index 1: Invalid option/return value name "foo:bar:baz"'), + ], + ]), + # bad parameter parsing (no escaping, ignore error): + ('M(', Context(), dict(errors='ignore'), [[]]), + ('M(foo', Context(), dict(errors='ignore'), [[]]), + ('L(foo)', Context(), dict(errors='ignore'), [[]]), + ('L(foo,bar', Context(), dict(errors='ignore'), [[]]), + ('L(foo), bar', Context(), dict(errors='ignore'), [[]]), + ('P(', Context(), dict(errors='ignore'), [[]]), + ('P(foo', Context(), dict(errors='ignore'), [[]]), + # bad module ref (ignore error): + ('M(foo)', Context(), dict(errors='ignore'), [[]]), + (' M(foo.bar)', Context(), dict(errors='ignore'), [[dom.TextPart(text=' ')]]), + (' M(foo. bar.baz)', Context(), dict(errors='ignore'), [[dom.TextPart(text=' ')]]), + (' M(foo) baz', Context(), dict(errors='ignore'), [ + [ + dom.TextPart(text=' '), + dom.TextPart(text=' baz'), + ], + ]), + # bad plugin ref (ignore error): + ('P(foo#bar)', Context(), dict(errors='ignore'), [[]]), + ('P(f o.b r.b z#bar)', Context(), dict(errors='ignore'), [[]]), + ('P(foo.bar.baz#b m)', Context(), dict(errors='ignore'), [[]]), + # bad option name/return value (ignore error): + ('O(f o.b r.b z#bam:foobar)', Context(), dict(errors='ignore'), [[]]), + ('O(foo.bar.baz#b m:foobar)', Context(), dict(errors='ignore'), [[]]), + ('O(foo:bar:baz)', Context(), dict(errors='ignore'), [[]]), +] + + +@pytest.mark.parametrize('paragraphs, context, kwargs, expected', TEST_PARSE_DATA) +def test_parse(paragraphs: t.Union[str, t.List[str]], context: Context, kwargs: t.Dict[str, t.Any], expected: t.List[dom.Paragraph]) -> None: + result = parse(paragraphs, context, **kwargs) + print(result) + assert result == expected + + +TEST_PARSE_THROW_DATA: t.List[t.Tuple[t.Union[str, t.List[str]], Context, t.Dict[str, t.Any], str]] = [ + # bad parameter parsing (no escaping, throw error): + ('M(', Context(), dict(errors='exception'), + 'While parsing M() at index 1: Cannot find closing ")" after last parameter', + ), + ('M(foo', Context(), dict(errors='exception'), + 'While parsing M() at index 1: Cannot find closing ")" after last parameter', + ), + ('L(foo)', Context(), dict(errors='exception'), + 'While parsing L() at index 1: Cannot find comma separating parameter 1 from the next one', + ), + ('L(foo,bar', Context(), dict(errors='exception'), + 'While parsing L() at index 1: Cannot find closing ")" after last parameter', + ), + ('L(foo), bar', Context(), dict(errors='exception'), + 'While parsing L() at index 1: Cannot find closing ")" after last parameter', + ), + ('P(', Context(), dict(errors='exception'), + 'While parsing P() at index 1: Cannot find closing ")" after last parameter', + ), + ('P(foo', Context(), dict(errors='exception'), + 'While parsing P() at index 1: Cannot find closing ")" after last parameter', + ), + # bad module ref (throw error): + ('M(foo)', Context(), dict(errors='exception'), + 'While parsing M() at index 1: Module name "foo" is not a FQCN', + ), + (' M(foo.bar)', Context(), dict(errors='exception'), + 'While parsing M() at index 2: Module name "foo.bar" is not a FQCN', + ), + (' M(foo. bar.baz)', Context(), dict(errors='exception'), + 'While parsing M() at index 3: Module name "foo. bar.baz" is not a FQCN', + ), + (' M(foo)', Context(), dict(errors='exception'), + 'While parsing M() at index 4: Module name "foo" is not a FQCN', + ), + # bad plugin ref (throw error): + ('P(foo)', Context(), dict(errors='exception'), + 'While parsing P() at index 1: Parameter "foo" is not of the form FQCN#type', + ), + ('P(f o.b r.b z#bar)', Context(), dict(errors='exception'), + 'While parsing P() at index 1: Plugin name "f o.b r.b z" is not a FQCN', + ), + ('P(foo.bar.baz#b m)', Context(), dict(errors='exception'), + 'While parsing P() at index 1: Plugin type "b m" is not valid', + ), + # bad option name/return value (throw error): + ('O(f o.b r.b z#bam:foobar)', Context(), dict(errors='exception'), + 'While parsing O() at index 1: Plugin name "f o.b r.b z" is not a FQCN', + ), + ('O(foo.bar.baz#b m:foobar)', Context(), dict(errors='exception'), + 'While parsing O() at index 1: Plugin type "b m" is not valid', + ), + ('O(foo:bar:baz)', Context(), dict(errors='exception'), + 'While parsing O() at index 1: Invalid option/return value name "foo:bar:baz"', + ), +] + + +@pytest.mark.parametrize('paragraphs, context, kwargs, exc_message', TEST_PARSE_THROW_DATA) +def test_parse_bad(paragraphs: t.Union[str, t.List[str]], context: Context, kwargs: t.Dict[str, t.Any], exc_message: str) -> None: + with pytest.raises(ValueError) as exc: + parse(paragraphs, context, **kwargs) + assert str(exc.value) == exc_message + + +TEST_TRIVIAL_PARSER = [ + '', + 'foo', + 'I(foo) B(bar) HORIZONTALLINE C(baz)' +] + + +@pytest.mark.parametrize('input', TEST_TRIVIAL_PARSER) +def test_trivial_parser(input: str) -> None: + parser = Parser([]) + result = parser.parse_string(input, Context()) + expected = [dom.TextPart(text=input)] if input else [] + assert result == expected diff --git a/tests/units/markup/test_parser_impl.py b/tests/units/markup/test_parser_impl.py new file mode 100644 index 00000000..ec47b09c --- /dev/null +++ b/tests/units/markup/test_parser_impl.py @@ -0,0 +1,65 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project + +import typing as t + +import pytest + +from antsibull_docs.markup._parser_impl import parse_parameters_escaped, parse_parameters_unescaped + + +ESCAPED_TESTS = [ + ['(a)', 1, 1, ['a'], 3, None], + ['(a,b)', 1, 1, ['a,b'], 5, None], + ['(a,b,c)', 1, 1, ['a,b,c'], 7, None], + ['(a,b)', 1, 2, ['a', 'b'], 5, None], + ['(a,b,c)', 1, 2, ['a', 'b,c'], 7, None], + ['(a,b,c)', 1, 3, ['a', 'b', 'c'], 7, None], + ['(a\\,,b\\,\\),c\\))', 1, 3, ['a,', 'b,)', 'c)'], 15, None], + ['(a', 1, 1, [''], 2, 'Cannot find closing ")" after last parameter'], + ['(a', 1, 2, [''], 2, 'Cannot find comma separating parameter 1 from the next one'], + ['(a,b', 1, 2, ['a', ''], 4, 'Cannot find closing ")" after last parameter'], +] + + +@pytest.mark.parametrize( + 'text, index, parameter_count, expected_result, expected_index, expected_error', + ESCAPED_TESTS, +) +def test_parse_parameters_escaped(text: str, index: int, parameter_count: int, + expected_result: t.List[str], expected_index: int, + expected_error: t.Optional[str]) -> None: + result, end_index, error = parse_parameters_escaped(text, index, parameter_count) + print(result, end_index, error) + assert result == expected_result + assert end_index == expected_index + assert error == expected_error + + +UNESCAPED_TESTS = [ + ['(a)', 1, 1, ['a'], 3, None], + ['(a,b)', 1, 1, ['a,b'], 5, None], + ['(a,b,c)', 1, 1, ['a,b,c'], 7, None], + ['(a,b)', 1, 2, ['a', 'b'], 5, None], + ['(a,b,c)', 1, 2, ['a', 'b,c'], 7, None], + ['(a,b,c)', 1, 3, ['a', 'b', 'c'], 7, None], + ['(a', 1, 1, [], 2, 'Cannot find closing ")" after last parameter'], + ['(a', 1, 2, [], 2, 'Cannot find comma separating parameter 1 from the next one'], + ['(a,b', 1, 2, ['a'], 4, 'Cannot find closing ")" after last parameter'], +] + + +@pytest.mark.parametrize( + 'text, index, parameter_count, expected_result, expected_index, expected_error', + UNESCAPED_TESTS, +) +def test_parse_parameters_unescaped(text: str, index: int, parameter_count: int, + expected_result: t.List[str], expected_index: int, + expected_error: t.Optional[str]) -> None: + result, end_index, error = parse_parameters_unescaped(text, index, parameter_count) + print(result, end_index, error) + assert result == expected_result + assert end_index == expected_index + assert error == expected_error diff --git a/tests/units/markup/test_rst.py b/tests/units/markup/test_rst.py new file mode 100644 index 00000000..39b6e6ea --- /dev/null +++ b/tests/units/markup/test_rst.py @@ -0,0 +1,19 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2023, Ansible Project + +from antsibull_docs.markup import dom +from antsibull_docs.markup.rst import rst_escape, to_rst + + +def test_rst_escape(): + assert rst_escape('') == '' + assert rst_escape(' foo ') == ' foo ' + assert rst_escape(' foo ', True) == '\\ foo \\ ' + assert rst_escape('\\<_>`*<_>*`\\') == '\\\\\\<\\_\\>\\`\\*\\<\\_\\>\\*\\`\\\\' + +def test_to_rst(): + assert to_rst([]) == '' + assert to_rst([[dom.TextPart(text='test')]]) == 'test' diff --git a/tests/units/markup/test_vectors.py b/tests/units/markup/test_vectors.py new file mode 100644 index 00000000..52598b17 --- /dev/null +++ b/tests/units/markup/test_vectors.py @@ -0,0 +1,109 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2022, Ansible Project + +import typing as t + +import pytest + +from antsibull_core.yaml import load_yaml_file + +from antsibull_docs.markup import dom +from antsibull_docs.markup.parser import parse, Context +from antsibull_docs.markup.html import to_html, to_html_plain +from antsibull_docs.markup.md import to_md +from antsibull_docs.markup.rst import to_rst +from antsibull_docs.markup.format import LinkProvider + + +class _TestLinkProvider(LinkProvider): + _plugin_link = None + _plugin_option_like_link = None + + def plugin_link(self, plugin: dom.PluginIdentifier) -> t.Optional[str]: + if self._plugin_link is not None: + return self._plugin_link(plugin) + return None + + def plugin_option_like_link(self, + plugin: dom.PluginIdentifier, + what: "t.Union[t.Literal['option'], t.Literal['retval']]", + name: t.List[str], current_plugin: bool) -> t.Optional[str]: + if self._plugin_option_like_link is not None: + return self._plugin_option_like_link(plugin, what, name, current_plugin) + return None + + def _update(self, config: t.Mapping[str, t.Any]): + if 'pluginLink.py' in config: + self._plugin_link = eval(config['pluginLink.py']) + if 'pluginOptionLikeLink.py' in config: + self._plugin_option_like_link = eval(config['pluginOptionLikeLink.py']) + + +TEST_DATA = sorted(load_yaml_file('test-vectors.yaml')['test_vectors'].items()) + + +@pytest.mark.parametrize('test_name, test_data', TEST_DATA, ids=[test_name for test_name, test_data in TEST_DATA]) +def test_vectors(test_name: str, test_data: t.Mapping[str, t.Any]) -> None: + parse_opts = {} + context_opts = {} + if test_data.get('parse_opts'): + if 'current_plugin' in test_data['parse_opts']: + context_opts['current_plugin'] = dom.PluginIdentifier( + fqcn=test_data['parse_opts']['current_plugin']['fqcn'], + type=test_data['parse_opts']['current_plugin']['type'], + ) + if 'errors' in test_data['parse_opts']: + context_opts['errors'] = test_data['parse_opts']['errors'] + if 'onlyClassicMarkup' in test_data['parse_opts']: + context_opts['only_classic_markup'] = test_data['parse_opts']['onlyClassicMarkup'] + parsed = parse(test_data['source'], Context(**context_opts), **parse_opts) + + html_opts = {} + html_link_provider = _TestLinkProvider() + if test_data.get('html_opts'): + if 'parStart' in test_data['html_opts']: + html_opts['par_start'] = test_data['html_opts']['parStart'] + if 'parEnd' in test_data['html_opts']: + html_opts['par_end'] = test_data['html_opts']['parEnd'] + if 'current_plugin' in test_data['html_opts']: + rst_opts['current_plugin'] = dom.PluginIdentifier( + fqcn=test_data['html_opts']['current_plugin']['fqcn'], + type=test_data['html_opts']['current_plugin']['type'], + ) + html_link_provider._update(test_data['html_opts']) + + md_opts = {} + md_link_provider = _TestLinkProvider() + if test_data.get('md_opts'): + if 'current_plugin' in test_data['md_opts']: + rst_opts['current_plugin'] = dom.PluginIdentifier( + fqcn=test_data['md_opts']['current_plugin']['fqcn'], + type=test_data['md_opts']['current_plugin']['type'], + ) + md_link_provider._update(test_data['md_opts']) + + rst_opts = {} + if test_data.get('rst_opts'): + if 'current_plugin' in test_data['rst_opts']: + rst_opts['current_plugin'] = dom.PluginIdentifier( + fqcn=test_data['rst_opts']['current_plugin']['fqcn'], + type=test_data['rst_opts']['current_plugin']['type'], + ) + + if 'html' in test_data: + result = to_html(parsed, link_provider=html_link_provider, **html_opts) + assert result == test_data['html'] + + if 'html_plain' in test_data: + result = to_html_plain(parsed, link_provider=html_link_provider, **html_opts) + assert result == test_data['html_plain'] + + if 'md' in test_data: + result = to_md(parsed, link_provider=md_link_provider, **md_opts) + assert result == test_data['md'] + + if 'rst' in test_data: + result = to_rst(parsed, **rst_opts) + assert result == test_data['rst'] diff --git a/tests/units/test_jinja2.py b/tests/units/test_jinja2.py index afd0c00b..2dbb434e 100644 --- a/tests/units/test_jinja2.py +++ b/tests/units/test_jinja2.py @@ -4,8 +4,7 @@ import pytest -from antsibull_docs.jinja2.filters import massage_author_name, move_first, to_ini_value, to_json -from antsibull_docs.jinja2.rstify import rst_escape, rst_ify +from antsibull_docs.jinja2.filters import massage_author_name, move_first, to_ini_value, to_json, rst_ify RST_IFY_DATA = { # No substitutions @@ -41,28 +40,6 @@ def test_rst_ify(text, expected): assert rst_ify(context, text) == expected -RST_ESCAPE_DATA = { - '': '', - 'no-op': 'no-op', - None: 'None', - 1: '1', - '*': '\\*', - '_': '\\_', - '<': '\\<', - '>': '\\>', - '`': '\\`', - '\\': '\\\\', - '\\*': '\\\\\\*', - '*\\': '\\*\\\\', - ':role:`test`': ':role:\\`test\\`', -} - - -@pytest.mark.parametrize('value, expected', RST_ESCAPE_DATA.items()) -def test_escape_ify(value, expected): - assert rst_escape(value) == expected - - MOVE_FIRST_DATA = [ ([], [], []), (['a', 'b', 'c'], ['d'], ['a', 'b', 'c']), diff --git a/tests/units/test_jinja2_parser.py b/tests/units/test_jinja2_parser.py deleted file mode 100644 index 52707037..00000000 --- a/tests/units/test_jinja2_parser.py +++ /dev/null @@ -1,137 +0,0 @@ -# Author: Felix Fontein -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: 2022, Ansible Project - -import typing as t - -import pytest - -from antsibull_docs.jinja2.parser import Command, CommandSet, ParsingException, convert_text - - -class TestCommand0(Command): - command = 'command0' - parameter_count = 0 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - assert len(parameters) == 0 - return f'EMPTY' - - -class TestCommand1A(Command): - command = 'command1a' - parameter_count = 1 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - assert len(parameters) == 1 - return f'FOOA{parameters!r}' - - -class TestCommand1B(Command): - command = 'command1b' - parameter_count = 1 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - assert len(parameters) == 1 - return f'FOOB{parameters!r}' - - -class TestCommand2A(Command): - command = 'command2a' - parameter_count = 2 - escaped_content = False - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - assert len(parameters) == 2 - return f'FOOC{parameters!r}' - - -class TestCommand2B(Command): - command = 'command2b' - parameter_count = 2 - escaped_content = True - - def handle(self, parameters: t.List[str], context: t.Any) -> str: - assert len(parameters) == 2 - return f'FOOD{parameters!r}' - - -COMMAND_SET = CommandSet([ - TestCommand0(), - TestCommand1A(), - TestCommand1B(), - TestCommand2A(), - TestCommand2B(), -]) - - -def _escape_regular(text: str) -> str: - return f'TEXT:{text!r}' - - -CONVERT_TEXT_DATA = { - 'nothing': "TEXT:'nothing'", - 'command0': "EMPTY", - 'a command0 b': "TEXT:'a 'EMPTYTEXT:' b'", - 'acommand0b': "TEXT:'acommand0b'", - 'command1a(foo)': "FOOA['foo']", - 'command1a( foo )': "FOOA[' foo ']", - 'command1a(foo, bar)': "FOOA['foo, bar']", - 'command1a(())': "FOOA['(']TEXT:')'", - r'command1a(\(\))': r"FOOA['\\(\\']TEXT:')'", - 'a command1a()b': "TEXT:'a 'FOOA['']TEXT:'b'", - 'a command1a() b': "TEXT:'a 'FOOA['']TEXT:' b'", - 'acommand1a()b': "TEXT:'acommand1a()b'", - 'command1b(foo)': "FOOB['foo']", - 'command1b( foo )': "FOOB[' foo ']", - 'command1b(foo, bar)': "FOOB['foo, bar']", - 'command1b(())': "FOOB['(']TEXT:')'", - r'command1b(\(\))': "FOOB['()']", - 'a command1b()b': "TEXT:'a 'FOOB['']TEXT:'b'", - 'acommand1b()b': "TEXT:'acommand1b()b'", - 'command2a(foo, bar)': "FOOC['foo', 'bar']", - 'command2a(foo,bar)': "FOOC['foo', 'bar']", - 'command2a( foo , bar , baz )': "FOOC[' foo', 'bar , baz ']", - 'command2a((,))': "FOOC['(', '']TEXT:')'", - r'command2a(\(,\))': r"FOOC['\\(', '\\']TEXT:')'", - 'a command2a(,)b': "TEXT:'a 'FOOC['', '']TEXT:'b'", - 'acommand2a(,)b': "TEXT:'acommand2a(,)b'", - 'command2b(foo, bar)': "FOOD['foo', 'bar']", - 'command2b(foo,bar)': "FOOD['foo', 'bar']", - 'command2b( foo , bar , baz )': "FOOD[' foo', 'bar , baz ']", - r'command2b( foo\ , bar , baz )': "FOOD[' foo ', 'bar , baz ']", - 'command2b((,))': "FOOD['(', '']TEXT:')'", - r'command2b(\(,\))': "FOOD['(', ')']", - 'a command2b(,)b': "TEXT:'a 'FOOD['', '']TEXT:'b'", - 'acommand2b(,)b': "TEXT:'acommand2b(,)b'", -} - - -@pytest.mark.parametrize('text, expected', CONVERT_TEXT_DATA.items()) -def test_convert_text(text, expected): - commands = COMMAND_SET - result = convert_text(text, commands, _escape_regular, None) - assert result == expected - - -CONVERT_TEXT_FAIL_DATA = { - 'command1a(': """Cannot find ")" closing after the last parameter for command "command1a" starting at index 0 in 'command1a('""", - 'command2a(,': """Cannot find ")" closing after the last parameter for command "command2a" starting at index 0 in 'command2a(,'""", - 'command2a(': """Cannot find comma separating parameter 1 from the next one for command "command2a" starting at index 0 in 'command2a('""", - 'command1b(': """Cannot find ")" closing after the last parameter for command "command1b" starting at index 0 in 'command1b('""", - 'command2b(,': """Cannot find ")" closing after the last parameter for command "command2b" starting at index 0 in 'command2b(,'""", - 'command2b(': """Cannot find comma separating parameter 1 from the next one for command "command2b" starting at index 0 in 'command2b('""", - r'command2b(\,)': r"""Cannot find comma separating parameter 1 from the next one for command "command2b" starting at index 0 in 'command2b(\\,)'""", -} - - -@pytest.mark.parametrize('text, expected_exc', CONVERT_TEXT_FAIL_DATA.items()) -def test_convert_text_fail(text, expected_exc): - commands = COMMAND_SET - with pytest.raises(ParsingException) as exc: - convert_text(text, commands, _escape_regular, None) - assert str(exc.value) == expected_exc