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 = {