diff --git a/pyproject.toml b/pyproject.toml index 089efab3e..1cb5f937c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ antsibull-changelog = ">= 0.10.0" asyncio-pool = "*" docutils = "*" importlib-metadata = {version = "*", python = "<3.8"} -jinja2 = "*" +jinja2 = ">= 3.0" # major/minor was introduced here packaging = ">= 20.0" perky = "*" diff --git a/src/antsibull/jinja2/filters.py b/src/antsibull/jinja2/filters.py index 01814e1b6..29cb572d7 100644 --- a/src/antsibull/jinja2/filters.py +++ b/src/antsibull/jinja2/filters.py @@ -5,14 +5,17 @@ """ import re +from functools import partial from html import escape as html_escape from urllib.parse import quote import typing as t -from jinja2.runtime import Undefined +from jinja2.runtime import Context, Undefined +from jinja2.utils import pass_context from ..logging import log +from ..semantic_helper import parse_option, parse_return_value, augment_plugin_name_type mlog = log.fields(mod=__name__) @@ -27,26 +30,127 @@ _LINK = re.compile(r"\bL\(([^)]+), *([^)]+)\)") _REF = re.compile(r"\bR\(([^)]+), *([^)]+)\)") _CONST = re.compile(r"\bC\(([^)]+)\)") -_SEM_OPTION_NAME = re.compile(r"\bO\(([^)]+)\)") -_SEM_OPTION_VALUE = re.compile(r"\bV\(([^)]+)\)") -_SEM_ENV_VARIABLE = re.compile(r"\bE\(([^)]+)\)") +_SEM_PARAMETER_STRING = r"\(((?:[^\\)]+|\\.)+)\)" +_SEM_OPTION_NAME = re.compile(r"\bO" + _SEM_PARAMETER_STRING) +_SEM_OPTION_VALUE = re.compile(r"\bV" + _SEM_PARAMETER_STRING) +_SEM_ENV_VARIABLE = re.compile(r"\bE" + _SEM_PARAMETER_STRING) +_SEM_RET_VALUE = re.compile(r"\bRV" + _SEM_PARAMETER_STRING) _RULER = re.compile(r"\bHORIZONTALLINE\b") - - -def _option_name_html(matcher): - text = matcher.group(1) - if '=' not in text and ':' not in text: - return f'{text}' - return f'{text}' - - -def html_ify(text): +_UNESCAPE = re.compile(r"\\(.)") + + +def extract_plugin_data(context: Context) -> t.Tuple[t.Optional[str], t.Optional[str]]: + plugin_fqcn = context.get('plugin_name') + plugin_type = context.get('plugin_type') + if plugin_fqcn is None or plugin_type is None: + return None, None + # if plugin_type == 'role': + # entry_point = context.get('entry_point', 'main') + # # FIXME: use entry_point + return plugin_fqcn, plugin_type + + +def _unescape_sem_value(text: str) -> str: + return _UNESCAPE.sub(r'\1', text) + + +def _check_plugin(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + matcher: 're.Match') -> None: + if plugin_fqcn is None or plugin_type is None: + raise Exception(f'The markup {matcher.group(0)} cannot be used outside a plugin or role') + + +def _create_error(text: str, error: str) -> str: # pylint:disable=unused-argument + return '...' # FIXME + + +def _option_name_html(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + matcher: 're.Match') -> str: + _check_plugin(plugin_fqcn, plugin_type, matcher) + text = _unescape_sem_value(matcher.group(1)) + try: + plugin_fqcn, plugin_type, option_link, option, value = parse_option( + text, plugin_fqcn, plugin_type, require_plugin=False) + except ValueError as exc: + return _create_error(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-{option_link.replace(".", "/")}' + link_start = ( + f'' + '' + ) + link_end = '' + else: + link_start = '' + link_end = '' + return ( + f'' + f'{strong_start}{link_start}{text}{link_end}{strong_end}' + ) + + +def _return_value_html(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + matcher: 're.Match') -> str: + _check_plugin(plugin_fqcn, plugin_type, matcher) + text = _unescape_sem_value(matcher.group(1)) + try: + plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value( + text, plugin_fqcn, plugin_type, require_plugin=False) + except ValueError as exc: + return _create_error(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-{rv_link.replace(".", "/")}' + link_start = ( + f'' + '' + ) + link_end = '' + else: + link_start = '' + link_end = '' + return f'{link_start}{text}{link_end}' + + +def _value_html(matcher: 're.Match') -> str: + text = _unescape_sem_value(matcher.group(1)) + return f'{text}' + + +def _env_var_html(matcher: 're.Match') -> str: + text = _unescape_sem_value(matcher.group(1)) + return f'{text}' + + +@pass_context +def html_ify(context: Context, text: str) -> str: ''' convert symbols like I(this is in italics) to valid HTML ''' flog = mlog.fields(func='html_ify') flog.fields(text=text).debug('Enter') _counts = {} + plugin_fqcn, plugin_type = extract_plugin_data(context) + text = html_escape(text) text, _counts['italic'] = _ITALIC.subn(r"\1", text) text, _counts['bold'] = _BOLD.subn(r"\1", text) @@ -59,11 +163,12 @@ def html_ify(text): text, _counts['link'] = _LINK.subn(r"\1", text) text, _counts['const'] = _CONST.subn( r"\1", text) - text, _counts['option-name'] = _SEM_OPTION_NAME.subn(_option_name_html, text) - text, _counts['option-value'] = _SEM_OPTION_VALUE.subn( - r"\1", text) - text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn( - r"\1", text) + text, _counts['option-name'] = _SEM_OPTION_NAME.subn( + partial(_option_name_html, plugin_fqcn, plugin_type), text) + text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_value_html, text) + text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_env_var_html, text) + text, _counts['return-value'] = _SEM_RET_VALUE.subn( + partial(_return_value_html, plugin_fqcn, plugin_type), text) text, _counts['ruler'] = _RULER.subn(r"
", text) text = text.strip() @@ -134,25 +239,42 @@ def _rst_ify_const(m: 're.Match') -> str: return f"\\ :literal:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ " -def _rst_ify_option_name(m): - return f"\\ :ansopt:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ " +def _rst_ify_option_name(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + m: 're.Match') -> str: + _check_plugin(plugin_fqcn, plugin_type, m) + text = _unescape_sem_value(m.group(1)) + text = augment_plugin_name_type(text, plugin_fqcn, plugin_type) + return f"\\ :ansopt:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " + +def _rst_ify_value(m: 're.Match') -> str: + text = _unescape_sem_value(m.group(1)) + return f"\\ :ansval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " -def _rst_ify_value(m): - return f"\\ :ansval:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ " +def _rst_ify_return_value(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str], + m: 're.Match') -> str: + _check_plugin(plugin_fqcn, plugin_type, m) + text = _unescape_sem_value(m.group(1)) + text = augment_plugin_name_type(text, plugin_fqcn, plugin_type) + return f"\\ :ansretval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " -def _rst_ify_envvar(m): - return f"\\ :envvar:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ " +def _rst_ify_envvar(m: 're.Match') -> str: + text = _unescape_sem_value(m.group(1)) + return f"\\ :envvar:`{rst_escape(text, escape_ending_whitespace=True)}`\\ " -def rst_ify(text): + +@pass_context +def rst_ify(context: Context, text: str) -> 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') _counts = {} + plugin_fqcn, plugin_type = extract_plugin_data(context) + text, _counts['italic'] = _ITALIC.subn(_rst_ify_italic, text) text, _counts['bold'] = _BOLD.subn(_rst_ify_bold, text) text, _counts['module'] = _MODULE.subn(_rst_ify_module, text) @@ -160,9 +282,12 @@ def rst_ify(text): text, _counts['url'] = _URL.subn(_rst_ify_url, text) text, _counts['ref'] = _REF.subn(_rst_ify_ref, text) text, _counts['const'] = _CONST.subn(_rst_ify_const, text) - text, _counts['option-name'] = _SEM_OPTION_NAME.subn(_rst_ify_option_name, text) + text, _counts['option-name'] = _SEM_OPTION_NAME.subn( + partial(_rst_ify_option_name, plugin_fqcn, plugin_type), text) text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_rst_ify_value, text) text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_rst_ify_envvar, text) + text, _counts['return-value'] = _SEM_RET_VALUE.subn( + partial(_rst_ify_return_value, plugin_fqcn, plugin_type), text) text, _counts['ruler'] = _RULER.subn('\n\n.. raw:: html\n\n
\n\n', text) flog.fields(counts=_counts).info('Number of macros converted to rst equivalents') diff --git a/src/antsibull/semantic_helper.py b/src/antsibull/semantic_helper.py new file mode 100644 index 000000000..22788b053 --- /dev/null +++ b/src/antsibull/semantic_helper.py @@ -0,0 +1,82 @@ +# Copyright: (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Helpers for parsing semantic markup. +""" + +import re + +import typing as t + + +_ARRAY_STUB_RE = re.compile(r'\[([^\]]*)\]') +_FQCN_TYPE_PREFIX_RE = re.compile(r'^([^.]+\.[^.]+\.[^#]+)#([a-z]+):(.*)$') +_IGNORE_MARKER = 'ignore:' + + +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[str, 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. + """ + 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 option name "{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[str, str, str, str, t.Optional[str]]: + """ + 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) + 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 + + +def augment_plugin_name_type(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str] + ) -> 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. + """ + 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}' diff --git a/src/sphinx_antsibull_ext/antsibull-minimal.css b/src/sphinx_antsibull_ext/antsibull-minimal.css index b7d35eae6..c3da0e58c 100644 --- a/src/sphinx_antsibull_ext/antsibull-minimal.css +++ b/src/sphinx_antsibull_ext/antsibull-minimal.css @@ -1,2 +1,2 @@ @charset "UTF-8"; -/* Copyright (c) Ansible and contributors */table.documentation-table{border-bottom:1px solid #000;border-right:1px solid #000}table.documentation-table th{background-color:#6ab0de}table.documentation-table td,table.documentation-table th{padding:4px;border-left:1px solid #000;border-top:1px solid #000}table.documentation-table td.elbow-placeholder{border-top:0;width:30px;min-width:30px}table.documentation-table td{vertical-align:top}table.documentation-table td:first-child{white-space:nowrap}table.documentation-table tr .ansibleOptionLink{display:inline-block;visibility:hidden}table.documentation-table tr .ansibleOptionLink:after{content:"🔗"}table.documentation-table tr:hover .ansibleOptionLink:after{visibility:visible}table.documentation-table tr:nth-child(odd){background-color:#fff}table.documentation-table tr:nth-child(2n){background-color:#e7f2fa}table.ansible-option-table{display:table;border-color:#000!important;height:1px}table.ansible-option-table tr{height:100%}table.ansible-option-table td,table.ansible-option-table th{border-color:#000!important;border-bottom:none!important;vertical-align:top!important}table.ansible-option-table th>p{font-size:medium!important}table.ansible-option-table thead tr{background-color:#6ab0de}table.ansible-option-table tbody .row-odd td{background-color:#fff!important}table.ansible-option-table tbody .row-even td{background-color:#e7f2fa!important}table.ansible-option-table ul>li>p{margin:0!important}table.ansible-option-table ul>li>div[class^=highlight]{margin-bottom:4px!important}table.ansible-option-table p.ansible-option-title{display:inline}table.ansible-option-table .ansible-option-type-line{font-size:small;margin-bottom:0}table.ansible-option-table .ansible-option-elements,table.ansible-option-table .ansible-option-type{color:purple}table.ansible-option-table .ansible-option-required{color:red}table.ansible-option-table .ansible-option-versionadded{font-style:italic;font-size:small;color:#006400}table.ansible-option-table .ansible-option-aliases{color:#006400}table.ansible-option-table .ansible-option-line{margin-top:8px}table.ansible-option-table .ansible-option-choices{font-weight:700}table.ansible-option-table .ansible-option-default{color:#00f}table.ansible-option-table .ansible-option-default-bold{color:#00f;font-weight:700}table.ansible-option-table .ansible-option-returned-bold{font-weight:700}table.ansible-option-table .ansible-option-sample{color:#00f;word-wrap:break-word;word-break:break-all}table.ansible-option-table .ansible-option-sample-bold{color:#000;font-weight:700}table.ansible-option-table .ansible-option-configuration{font-weight:700}table.ansible-option-table .ansibleOptionLink{display:inline-block;visibility:hidden}table.ansible-option-table .ansibleOptionLink:after{content:"🔗"}table.ansible-option-table p{margin:0 0 8px}table.ansible-option-table tr:hover .ansibleOptionLink:after{visibility:visible}table.ansible-option-table td{padding:0!important;white-space:normal}table.ansible-option-table td>div.ansible-option-cell{padding:8px 16px;border-top:1px solid #000}table.ansible-option-table td:first-child{height:inherit;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:first-child>div.ansible-option-cell{height:inherit;-webkit-box-flex:1;-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;white-space:nowrap}table.ansible-option-table .ansible-option-indent{margin-left:2em;border-right:1px solid #000}dl.ansible-attributes dd:first-of-type,dl.ansible-attributes dt:first-of-type{display:none}dl.ansible-attributes .ansible-attribute-support-label,dl.ansible-attributes .ansible-attribute-support-property{font-weight:700}dl.ansible-attributes .ansible-attribute-support-none{font-weight:700;color:red}dl.ansible-attributes .ansible-attribute-support-partial{font-weight:700;color:#a5a500}dl.ansible-attributes .ansible-attribute-support-full{font-weight:700;color:green}dl.ansible-attributes .ansible-attribute-details{font-style:italic}@media (min-width:1000px){dl.ansible-attributes{display:grid;border:1px solid #777;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}dl.ansible-attributes dd:first-of-type,dl.ansible-attributes dt:first-of-type{display:table-row;font-weight:700}dl.ansible-attributes dd:not(:first-of-type),dl.ansible-attributes dt:not(:first-of-type){border-top:1px solid #777;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}dl.ansible-attributes dt{margin:0!important;padding:.5rem!important}dl.ansible-attributes dt:after{display:none}dl.ansible-attributes dd{display:grid!important;grid-template-columns:2fr 4fr;margin:0!important}dl.ansible-attributes .ansible-attribute-desc,dl.ansible-attributes .ansible-attribute-support{border-left:1px solid #777;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;margin:0!important;padding:.5rem!important}dl.ansible-attributes .ansible-attribute-support-label{display:none}}@media (max-width:1200px){table.ansible-option-table{display:block;height:unset;border:none!important}table.ansible-option-table thead{display:none}table.ansible-option-table tbody,table.ansible-option-table td,table.ansible-option-table tr{display:block;border:none!important}table.ansible-option-table tbody .row-even td,table.ansible-option-table tbody .row-odd td{background-color:unset!important}table.ansible-option-table td>div.ansible-option-cell{border-top:none}table.ansible-option-table td:first-child>div.ansible-option-cell{background-color:#e7f2fa!important}table.ansible-option-table td:last-child{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:last-child>div.ansible-option-cell{margin-left:1em}table.ansible-option-table .ansible-option-indent,table.ansible-option-table .ansible-option-indent-desc{margin-left:1em;border:none;border-right:3px solid #e7f2fa}} \ No newline at end of file +/* Copyright (c) Ansible and contributors */table.documentation-table{border-bottom:1px solid #000;border-right:1px solid #000}table.documentation-table th{background-color:#6ab0de}table.documentation-table td,table.documentation-table th{padding:4px;border-left:1px solid #000;border-top:1px solid #000}table.documentation-table td.elbow-placeholder{border-top:0;width:30px;min-width:30px}table.documentation-table td{vertical-align:top}table.documentation-table td:first-child{white-space:nowrap}table.documentation-table tr .ansibleOptionLink{display:inline-block;visibility:hidden}table.documentation-table tr .ansibleOptionLink:after{content:"🔗"}table.documentation-table tr:hover .ansibleOptionLink:after{visibility:visible}table.documentation-table tr:nth-child(odd){background-color:#fff}table.documentation-table tr:nth-child(2n){background-color:#e7f2fa}table.ansible-option-table{display:table;border-color:#000!important;height:1px}table.ansible-option-table tr{height:100%}table.ansible-option-table td,table.ansible-option-table th{border-color:#000!important;border-bottom:none!important;vertical-align:top!important}table.ansible-option-table th>p{font-size:medium!important}table.ansible-option-table thead tr{background-color:#6ab0de}table.ansible-option-table tbody .row-odd td{background-color:#fff!important}table.ansible-option-table tbody .row-even td{background-color:#e7f2fa!important}table.ansible-option-table ul>li>p{margin:0!important}table.ansible-option-table ul>li>div[class^=highlight]{margin-bottom:4px!important}table.ansible-option-table p.ansible-option-title{display:inline}table.ansible-option-table .ansible-option-type-line{font-size:small;margin-bottom:0}table.ansible-option-table .ansible-option-elements,table.ansible-option-table .ansible-option-type{color:purple}table.ansible-option-table .ansible-option-required{color:red}table.ansible-option-table .ansible-option-versionadded{font-style:italic;font-size:small;color:#006400}table.ansible-option-table .ansible-option-aliases{color:#006400}table.ansible-option-table .ansible-option-line{margin-top:8px}table.ansible-option-table .ansible-option-choices{font-weight:700}table.ansible-option-table .ansible-option-default{color:#00f}table.ansible-option-table .ansible-option-default-bold{color:#00f;font-weight:700}table.ansible-option-table .ansible-option-returned-bold{font-weight:700}table.ansible-option-table .ansible-option-sample{color:#00f;word-wrap:break-word;word-break:break-all}table.ansible-option-table .ansible-option-sample-bold{color:#000;font-weight:700}table.ansible-option-table .ansible-option-configuration{font-weight:700}table.ansible-option-table .ansibleOptionLink{display:inline-block;visibility:hidden}table.ansible-option-table .ansibleOptionLink:after{content:"🔗"}table.ansible-option-table p{margin:0 0 8px}table.ansible-option-table tr:hover .ansibleOptionLink:after{visibility:visible}table.ansible-option-table td{padding:0!important;white-space:normal}table.ansible-option-table td>div.ansible-option-cell{padding:8px 16px;border-top:1px solid #000}table.ansible-option-table td:first-child{height:inherit;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:first-child>div.ansible-option-cell{height:inherit;-webkit-box-flex:1;-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;white-space:nowrap}table.ansible-option-table .ansible-option-indent{margin-left:2em;border-right:1px solid #000}dl.ansible-attributes dd:first-of-type,dl.ansible-attributes dt:first-of-type{display:none}dl.ansible-attributes .ansible-attribute-support-label,dl.ansible-attributes .ansible-attribute-support-property{font-weight:700}dl.ansible-attributes .ansible-attribute-support-none{font-weight:700;color:red}dl.ansible-attributes .ansible-attribute-support-partial{font-weight:700;color:#a5a500}dl.ansible-attributes .ansible-attribute-support-full{font-weight:700;color:green}dl.ansible-attributes .ansible-attribute-details{font-style:italic}@media (min-width:1000px){dl.ansible-attributes{display:grid;border:1px solid #777;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}dl.ansible-attributes dd:first-of-type,dl.ansible-attributes dt:first-of-type{display:table-row;font-weight:700}dl.ansible-attributes dd:not(:first-of-type),dl.ansible-attributes dt:not(:first-of-type){border-top:1px solid #777;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}dl.ansible-attributes dt{margin:0!important;padding:.5rem!important}dl.ansible-attributes dt:after{display:none}dl.ansible-attributes dd{display:grid!important;grid-template-columns:2fr 4fr;margin:0!important}dl.ansible-attributes .ansible-attribute-desc,dl.ansible-attributes .ansible-attribute-support{border-left:1px solid #777;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;margin:0!important;padding:.5rem!important}dl.ansible-attributes .ansible-attribute-support-label{display:none}}@media (max-width:1200px){table.ansible-option-table{display:block;height:unset;border:none!important}table.ansible-option-table thead{display:none}table.ansible-option-table tbody,table.ansible-option-table td,table.ansible-option-table tr{display:block;border:none!important}table.ansible-option-table tbody .row-even td,table.ansible-option-table tbody .row-odd td{background-color:unset!important}table.ansible-option-table td>div.ansible-option-cell{border-top:none}table.ansible-option-table td:first-child>div.ansible-option-cell{background-color:#e7f2fa!important}table.ansible-option-table td:last-child{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:last-child>div.ansible-option-cell{margin-left:1em}table.ansible-option-table .ansible-option-indent,table.ansible-option-table .ansible-option-indent-desc{margin-left:1em;border:none;border-right:3px solid #e7f2fa}}.ansible-option-value a.reference.internal,.ansible-option-value a.reference.internal:hover,.ansible-option a.reference.internal,.ansible-option a.reference.internal:hover,.ansible-return-value a.reference.internal,.ansible-return-value a.reference.internal:hover{color:unset} \ No newline at end of file diff --git a/src/sphinx_antsibull_ext/css/antsibull-minimal.scss b/src/sphinx_antsibull_ext/css/antsibull-minimal.scss index d5262fc42..f4ed0b38e 100644 --- a/src/sphinx_antsibull_ext/css/antsibull-minimal.scss +++ b/src/sphinx_antsibull_ext/css/antsibull-minimal.scss @@ -347,3 +347,13 @@ dl.ansible-attributes { } } } + +.ansible-option, .ansible-option-value, .ansible-return-value { + a.reference.internal { + color: unset; + + &:hover { + color: unset; + } + } +} diff --git a/src/sphinx_antsibull_ext/roles.py b/src/sphinx_antsibull_ext/roles.py index 0454ebe64..e79384547 100644 --- a/src/sphinx_antsibull_ext/roles.py +++ b/src/sphinx_antsibull_ext/roles.py @@ -6,9 +6,59 @@ Add roles for semantic markup. ''' +import typing as t + from docutils import nodes +from sphinx import addnodes + +from antsibull.semantic_helper import parse_option, parse_return_value + + +def _create_option_reference(plugin_fqcn: str, plugin_type: str, option: str) -> t.Optional[str]: + if not plugin_fqcn or not plugin_type: + return None + # TODO: handle role arguments (entrypoint!) + ref = option.replace(".", "/") + return f'ansible_collections.{plugin_fqcn}_{plugin_type}__parameter-{ref}' + + +def _create_return_value_reference(plugin_fqcn: str, plugin_type: str, return_value: str + ) -> t.Optional[str]: + if not plugin_fqcn or not plugin_type: + return None + ref = return_value.replace(".", "/") + return f'ansible_collections.{plugin_fqcn}_{plugin_type}__return-{ref}' + + +def _create_ref_or_not(create_ref: t.Callable[[str, str, str], t.Optional[str]], + plugin_fqcn: str, plugin_type: str, ref_parameter: str, + text: str) -> t.Tuple[str, t.List[t.Any]]: + ref = create_ref(plugin_fqcn, plugin_type, ref_parameter) + if ref is None: + return text, [] + # The content node will be replaced by Sphinx anyway, so it doesn't matter what kind + # of node we are using... + content = nodes.literal(text, text) + options = { + 'reftype': 'ref', + 'refdomain': 'std', + 'refexplicit': True, + 'refwarn': True, + } + refnode = addnodes.pending_xref(text, content, **options) # pyre-ignore[19] + refnode['reftarget'] = ref # pyre-ignore[16] + return '', [refnode] + + +# pylint:disable-next=unused-argument +def _create_error(rawtext: str, text: str, error: str) -> t.Tuple[t.List[t.Any], t.List[str]]: + node = ... # FIXME + return [node], [] + + +# pylint:disable-next=unused-argument,dangerous-default-value def option_role(name, rawtext, text, lineno, inliner, options={}, content=[]): """Format Ansible option key, or option key-value. @@ -24,18 +74,29 @@ def option_role(name, rawtext, text, lineno, inliner, options={}, content=[]): :param options: Directive options for customization. :param content: The directive content for customization. """ - children = [] classes = [] - if '=' not in text and ':' not in text: - children.append(nodes.strong(rawtext, text)) - rawtext = '' - text = '' + try: + plugin_fqcn, plugin_type, option_link, option, value = parse_option( + text.replace('\x00', ''), '', '', require_plugin=False) + except ValueError as exc: + return _create_error(rawtext, text, str(exc)) + if value is None: + text = f'{option}' classes.append('ansible-option') else: + text = f'{option}={value}' classes.append('ansible-option-value') - return [nodes.literal(rawtext, text, *children, classes=classes)], [] + text, subnodes = _create_ref_or_not( + _create_option_reference, plugin_fqcn, plugin_type, option_link, text) + if value is None: + content = nodes.strong(rawtext, text, *subnodes) + content = nodes.literal(rawtext, '', content, classes=classes) + else: + content = nodes.literal(rawtext, text, *subnodes, classes=classes) + return [content], [] +# pylint:disable-next=unused-argument,dangerous-default-value def value_role(name, rawtext, text, lineno, inliner, options={}, content=[]): """Format Ansible option value. @@ -54,9 +115,41 @@ def value_role(name, rawtext, text, lineno, inliner, options={}, content=[]): return [nodes.literal(rawtext, text, classes=['ansible-value'])], [] +# pylint:disable-next=unused-argument,dangerous-default-value +def return_value_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Format Ansible option value. + + Returns 2 part tuple containing list of nodes to insert into the + document and a list of system messages. Both are allowed to be + empty. + + :param name: The role name used in the document. + :param rawtext: The entire markup snippet, with role. + :param text: The text marked with the role. + :param lineno: The line number where rawtext appears in the input. + :param inliner: The inliner instance that called us. + :param options: Directive options for customization. + :param content: The directive content for customization. + """ + classes = ['ansible-return-value'] + try: + plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value( + text.replace('\x00', ''), '', '', require_plugin=False) + except ValueError as exc: + return _create_error(rawtext, text, str(exc)) + if value is None: + text = f'{rv}' + else: + text = f'{rv}={value}' + text, subnodes = _create_ref_or_not( + _create_return_value_reference, plugin_fqcn, plugin_type, rv_link, text) + return [nodes.literal(rawtext, text, *subnodes, classes=classes)], [] + + ROLES = { 'ansopt': option_role, 'ansval': value_role, + 'ansretval': return_value_role, } diff --git a/stubs/jinja2/utils.pyi b/stubs/jinja2/utils.pyi new file mode 100644 index 000000000..7bc79be36 --- /dev/null +++ b/stubs/jinja2/utils.pyi @@ -0,0 +1,7 @@ +import typing as t + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +def pass_context(f: F) -> F: ... +def pass_eval_context(f: F) -> F: ... +def pass_environment(f: F) -> F: ... diff --git a/tests/units/test_jinja2.py b/tests/units/test_jinja2.py index 03875d2b3..f21236674 100644 --- a/tests/units/test_jinja2.py +++ b/tests/units/test_jinja2.py @@ -30,7 +30,11 @@ @pytest.mark.parametrize('text, expected', RST_IFY_DATA.items()) def test_rst_ify(text, expected): - assert rst_ify(text) == expected + context = { + 'plugin_name': 'foo.bar.baz', + 'plugin_type': 'module', + } + assert rst_ify(context, text) == expected RST_ESCAPE_DATA = {