diff --git a/CHANGES b/CHANGES index 719de31ce1a..4da76996d03 100644 --- a/CHANGES +++ b/CHANGES @@ -29,6 +29,9 @@ Features added * #6319: viewcode: Add :confval:`viewcode_line_numbers` to control whether line numbers are added to rendered source code. Patch by Ben Krikler. +* #9662: Add the ``:no-typesetting:`` option to suppress textual output + and only create a linkable anchor. + Patch by Latosha Maltba. Bugs fixed ---------- diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index dbaa36e3d98..da53f7efbd8 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -54,6 +54,9 @@ can give the directive option flag ``:nocontentsentry:``. If you want to typeset an object description, without even making it available for cross-referencing, you can give the directive option flag ``:noindex:`` (which implies ``:noindexentry:``). +If you do not want to typeset anything, you can give the directive option flag +``:no-typesetting:``. This can for example be used to create only a target and +index entry for later reference. Though, note that not every directive in every domain may support these options. @@ -65,6 +68,10 @@ options. The directive option ``:nocontentsentry:`` in the Python, C, C++, Javascript, and reStructuredText domains. +.. versionadded:: 7.2 + The directive option ``no-typesetting`` in the Python, C, C++, Javascript, + and reStructuredText domains. + An example using a Python domain directive:: .. py:function:: spam(eggs) @@ -91,6 +98,23 @@ you could say :: As you can see, both directive and role names contain the domain name and the directive name. +The directive option ``:no-typesetting:`` can be used to create a target +(and index entry) which can later be referenced +by the roles provided by the domain. +This is particularly useful for literate programming: + +.. code-block:: rst + + .. py:function:: spam(eggs) + :no-typesetting: + + .. code:: + + def spam(eggs): + pass + + The function :py:func:`spam` does nothing. + .. rubric:: Default Domain For documentation describing objects from solely one domain, authors will not diff --git a/sphinx/directives/__init__.py b/sphinx/directives/__init__.py index 2328067df4c..2f10649bb09 100644 --- a/sphinx/directives/__init__.py +++ b/sphinx/directives/__init__.py @@ -57,6 +57,7 @@ class ObjectDescription(SphinxDirective, Generic[ObjDescT]): 'noindex': directives.flag, 'noindexentry': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, } # types of doc fields that this directive handles, see sphinx.util.docfields @@ -218,6 +219,7 @@ def run(self) -> list[Node]: node['noindex'] = noindex = ('noindex' in self.options) node['noindexentry'] = ('noindexentry' in self.options) node['nocontentsentry'] = ('nocontentsentry' in self.options) + node['no-typesetting'] = ('no-typesetting' in self.options) if self.domain: node['classes'].append(self.domain) node['classes'].append(node['objtype']) @@ -270,6 +272,19 @@ def run(self) -> list[Node]: DocFieldTransformer(self).transform_all(contentnode) self.env.temp_data['object'] = None self.after_content() + + if node['no-typesetting']: + # Attempt to return the index node, and a new target node + # containing all the ids of this node and its children. + # If ``:noindex:`` is set, or there are no ids on the node + # or any of its children, then just return the index node, + # as Docutils expects a target node to have at least one id. + if node_ids := [node_id for el in node.findall(nodes.Element) + for node_id in el.get('ids', ())]: + target_node = nodes.target(ids=node_ids) + self.set_source_info(target_node) + return [self.indexnode, target_node] + return [self.indexnode] return [self.indexnode, node] diff --git a/sphinx/domains/c.py b/sphinx/domains/c.py index 8fe0ed47cd1..af0ca6230c3 100644 --- a/sphinx/domains/c.py +++ b/sphinx/domains/c.py @@ -3166,6 +3166,7 @@ class CObject(ObjectDescription[ASTDeclaration]): option_spec: OptionSpec = { 'noindexentry': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, 'single-line-parameter-list': directives.flag, } diff --git a/sphinx/domains/cpp.py b/sphinx/domains/cpp.py index 35620dac298..d462f7d4b46 100644 --- a/sphinx/domains/cpp.py +++ b/sphinx/domains/cpp.py @@ -7214,6 +7214,7 @@ class CPPObject(ObjectDescription[ASTDeclaration]): option_spec: OptionSpec = { 'noindexentry': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, 'tparam-line-spec': directives.flag, 'single-line-parameter-list': directives.flag, } diff --git a/sphinx/domains/javascript.py b/sphinx/domains/javascript.py index 1ee4d8c3c0e..94e568f90ca 100644 --- a/sphinx/domains/javascript.py +++ b/sphinx/domains/javascript.py @@ -46,6 +46,7 @@ class JSObject(ObjectDescription[tuple[str, str]]): 'noindex': directives.flag, 'noindexentry': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, 'single-line-parameter-list': directives.flag, } @@ -293,6 +294,7 @@ class JSModule(SphinxDirective): option_spec: OptionSpec = { 'noindex': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, } def run(self) -> list[Node]: @@ -316,12 +318,13 @@ def run(self) -> list[Node]: domain.note_object(mod_name, 'module', node_id, location=(self.env.docname, self.lineno)) - target = nodes.target('', '', ids=[node_id], ismod=True) - self.state.document.note_explicit_target(target) - ret.append(target) + # The node order is: index node first, then target node indextext = _('%s (module)') % mod_name inode = addnodes.index(entries=[('single', indextext, node_id, '', None)]) ret.append(inode) + target = nodes.target('', '', ids=[node_id], ismod=True) + self.state.document.note_explicit_target(target) + ret.append(target) ret.extend(content_node.children) return ret diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index e31a7c26a63..416bee1cdf6 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -662,6 +662,7 @@ class PyObject(ObjectDescription[tuple[str, str]]): 'noindex': directives.flag, 'noindexentry': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, 'single-line-parameter-list': directives.flag, 'single-line-type-parameter-list': directives.flag, 'module': directives.unchanged, @@ -1262,6 +1263,7 @@ class PyModule(SphinxDirective): 'synopsis': lambda x: x, 'noindex': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, 'deprecated': directives.flag, } @@ -1294,10 +1296,11 @@ def run(self) -> list[Node]: # the platform and synopsis aren't printed; in fact, they are only # used in the modindex currently - ret.append(target) indextext = f'module; {modname}' inode = addnodes.index(entries=[('pair', indextext, node_id, '', None)]) + # The node order is: index node first, then target node. ret.append(inode) + ret.append(target) ret.extend(content_node.children) return ret diff --git a/sphinx/domains/rst.py b/sphinx/domains/rst.py index 2822aa7ff1a..e850653ee20 100644 --- a/sphinx/domains/rst.py +++ b/sphinx/domains/rst.py @@ -37,6 +37,7 @@ class ReSTMarkup(ObjectDescription[str]): 'noindex': directives.flag, 'noindexentry': directives.flag, 'nocontentsentry': directives.flag, + 'no-typesetting': directives.flag, } def add_target_and_index(self, name: str, sig: str, signode: desc_signature) -> None: diff --git a/sphinx/transforms/__init__.py b/sphinx/transforms/__init__.py index 21beb6c3a90..3d6c447e36e 100644 --- a/sphinx/transforms/__init__.py +++ b/sphinx/transforms/__init__.py @@ -414,6 +414,77 @@ def apply(self, **kwargs: Any) -> None: ) +class ReorderConsecutiveTargetAndIndexNodes(SphinxTransform): + """Index nodes interspersed between target nodes prevent other + Transformations from combining those target nodes, + e.g. ``PropagateTargets``. This transformation reorders them: + + Given the following ``document`` as input:: + + + + + + + + + + The transformed result will be:: + + + + + + + + + """ + + # This transform MUST run before ``PropagateTargets``. + default_priority = 220 + + def apply(self, **kwargs: Any) -> None: + for target in self.document.findall(nodes.target): + _reorder_index_target_nodes(target) + + +def _reorder_index_target_nodes(start_node: nodes.target) -> None: + """Sort target and index nodes. + + Find all consecutive target and index nodes starting from ``start_node``, + and move all index nodes to before the first target node. + """ + nodes_to_reorder: list[nodes.target | addnodes.index] = [] + + # Note that we cannot use 'condition' to filter, + # as we want *consecutive* target & index nodes. + node: nodes.Node + for node in start_node.findall(descend=False, siblings=True): + if isinstance(node, (nodes.target, addnodes.index)): + nodes_to_reorder.append(node) + continue + break # must be a consecutive run of target or index nodes + + if len(nodes_to_reorder) < 2: + return # Nothing to reorder + + parent = nodes_to_reorder[0].parent + if parent == nodes_to_reorder[-1].parent: + first_idx = parent.index(nodes_to_reorder[0]) + last_idx = parent.index(nodes_to_reorder[-1]) + if first_idx + len(nodes_to_reorder) - 1 == last_idx: + parent[first_idx:last_idx + 1] = sorted(nodes_to_reorder, key=_sort_key) + + +def _sort_key(node: nodes.Node) -> int: + # Must be a stable sort. + if isinstance(node, addnodes.index): + return 0 + if isinstance(node, nodes.target): + return 1 + raise ValueError(f'_sort_key called with unexpected node type {type(node)!r}') + + def setup(app: Sphinx) -> dict[str, Any]: app.add_transform(ApplySourceWorkaround) app.add_transform(ExtraTranslatableNodes) @@ -430,6 +501,7 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_transform(DoctreeReadEvent) app.add_transform(ManpageLink) app.add_transform(GlossarySorter) + app.add_transform(ReorderConsecutiveTargetAndIndexNodes) return { 'version': 'builtin', diff --git a/tests/test_directives_no_typesetting.py b/tests/test_directives_no_typesetting.py new file mode 100644 index 00000000000..f22e2497f6c --- /dev/null +++ b/tests/test_directives_no_typesetting.py @@ -0,0 +1,108 @@ +"""Tests the directives""" + +import pytest +from docutils import nodes + +from sphinx import addnodes +from sphinx.testing import restructuredtext +from sphinx.testing.util import assert_node + +DOMAINS = [ + # directive, noindex, noindexentry, signature of f, signature of g, index entry of g + ('c:function', False, True, 'void f()', 'void g()', ('single', 'g (C function)', 'c.g', '', None)), + ('cpp:function', False, True, 'void f()', 'void g()', ('single', 'g (C++ function)', '_CPPv41gv', '', None)), + ('js:function', True, True, 'f()', 'g()', ('single', 'g() (built-in function)', 'g', '', None)), + ('py:function', True, True, 'f()', 'g()', ('pair', 'built-in function; g()', 'g', '', None)), + ('rst:directive', True, False, 'f', 'g', ('single', 'g (directive)', 'directive-g', '', None)), + ('cmdoption', True, False, 'f', 'g', ('pair', 'command line option; g', 'cmdoption-arg-g', '', None)), + ('envvar', True, False, 'f', 'g', ('single', 'environment variable; g', 'envvar-g', '', None)), +] + + +@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS) +def test_object_description_no_typesetting(app, directive, noindex, noindexentry, sig_f, sig_g, index_g): + text = (f'.. {directive}:: {sig_f}\n' + f' :no-typesetting:\n') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, nodes.target)) + + +@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS) +def test_object_description_no_typesetting_twice(app, directive, noindex, noindexentry, sig_f, sig_g, index_g): + text = (f'.. {directive}:: {sig_f}\n' + f' :no-typesetting:\n' + f'.. {directive}:: {sig_g}\n' + f' :no-typesetting:\n') + doctree = restructuredtext.parse(app, text) + # Note that all index nodes come before the target nodes + assert_node(doctree, (addnodes.index, addnodes.index, nodes.target, nodes.target)) + + +@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS) +def test_object_description_no_typesetting_noindex_orig(app, directive, noindex, noindexentry, sig_f, sig_g, index_g): + if not noindex: + pytest.skip(f'{directive} does not support :noindex: option') + text = (f'.. {directive}:: {sig_f}\n' + f' :noindex:\n' + f'.. {directive}:: {sig_g}\n') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, addnodes.desc, addnodes.index, addnodes.desc)) + assert_node(doctree[2], addnodes.index, entries=[index_g]) + + +@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS) +def test_object_description_no_typesetting_noindex(app, directive, noindex, noindexentry, sig_f, sig_g, index_g): + if not noindex: + pytest.skip(f'{directive} does not support :noindex: option') + text = (f'.. {directive}:: {sig_f}\n' + f' :noindex:\n' + f' :no-typesetting:\n' + f'.. {directive}:: {sig_g}\n' + f' :no-typesetting:\n') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, addnodes.index, nodes.target)) + assert_node(doctree[0], addnodes.index, entries=[]) + assert_node(doctree[1], addnodes.index, entries=[index_g]) + + +@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS) +def test_object_description_no_typesetting_noindexentry(app, directive, noindex, noindexentry, sig_f, sig_g, index_g): + if not noindexentry: + pytest.skip(f'{directive} does not support :noindexentry: option') + text = (f'.. {directive}:: {sig_f}\n' + f' :noindexentry:\n' + f' :no-typesetting:\n' + f'.. {directive}:: {sig_g}\n' + f' :no-typesetting:\n') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, addnodes.index, nodes.target, nodes.target)) + assert_node(doctree[0], addnodes.index, entries=[]) + assert_node(doctree[1], addnodes.index, entries=[index_g]) + + +@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS) +def test_object_description_no_typesetting_code(app, directive, noindex, noindexentry, sig_f, sig_g, index_g): + text = (f'.. {directive}:: {sig_f}\n' + f' :no-typesetting:\n' + f'.. {directive}:: {sig_g}\n' + f' :no-typesetting:\n' + f'.. code::\n' + f'\n' + f' code\n') + doctree = restructuredtext.parse(app, text) + # Note that all index nodes come before the targets + assert_node(doctree, (addnodes.index, addnodes.index, nodes.target, nodes.target, nodes.literal_block)) + + +@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS) +def test_object_description_no_typesetting_heading(app, directive, noindex, noindexentry, sig_f, sig_g, index_g): + text = (f'.. {directive}:: {sig_f}\n' + f' :no-typesetting:\n' + f'.. {directive}:: {sig_g}\n' + f' :no-typesetting:\n' + f'\n' + f'Heading\n' + f'=======\n') + doctree = restructuredtext.parse(app, text) + # Note that all index nodes come before the targets and the heading is floated before those. + assert_node(doctree, (nodes.title, addnodes.index, addnodes.index, nodes.target, nodes.target)) diff --git a/tests/test_domain_js.py b/tests/test_domain_js.py index e927ad07244..a0a931280c7 100644 --- a/tests/test_domain_js.py +++ b/tests/test_domain_js.py @@ -177,11 +177,11 @@ def test_get_full_qualified_name(): def test_js_module(app): text = ".. js:module:: sphinx" doctree = restructuredtext.parse(app, text) - assert_node(doctree, (nodes.target, - addnodes.index)) - assert_node(doctree[0], nodes.target, ids=["module-sphinx"]) - assert_node(doctree[1], addnodes.index, + assert_node(doctree, (addnodes.index, + nodes.target)) + assert_node(doctree[0], addnodes.index, entries=[("single", "sphinx (module)", "module-sphinx", "", None)]) + assert_node(doctree[1], nodes.target, ids=["module-sphinx"]) def test_js_function(app): diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 8a3e378b103..8d74781502a 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -660,9 +660,9 @@ def test_pydata(app): " :type: int\n") domain = app.env.get_domain('py') doctree = restructuredtext.parse(app, text) - assert_node(doctree, (nodes.target, - addnodes.index, + assert_node(doctree, (addnodes.index, addnodes.index, + nodes.target, [desc, ([desc_signature, ([desc_addname, "example."], [desc_name, "var"], [desc_annotation, ([desc_sig_punctuation, ':'], @@ -685,9 +685,9 @@ def test_pyfunction(app): [desc, ([desc_signature, ([desc_name, "func1"], [desc_parameterlist, ()])], [desc_content, ()])], - nodes.target, addnodes.index, addnodes.index, + nodes.target, [desc, ([desc_signature, ([desc_annotation, ([desc_sig_keyword, 'async'], desc_sig_space)], [desc_addname, "example."], @@ -696,9 +696,9 @@ def test_pyfunction(app): [desc_content, ()])])) assert_node(doctree[0], addnodes.index, entries=[('pair', 'built-in function; func1()', 'func1', '', None)]) - assert_node(doctree[3], addnodes.index, + assert_node(doctree[2], addnodes.index, entries=[('pair', 'module; example', 'module-example', '', None)]) - assert_node(doctree[4], addnodes.index, + assert_node(doctree[3], addnodes.index, entries=[('single', 'func2() (in module example)', 'example.func2', '', None)]) assert 'func1' in domain.objects @@ -1043,9 +1043,9 @@ def test_info_field_list(app): doctree = restructuredtext.parse(app, text) print(doctree) - assert_node(doctree, (nodes.target, - addnodes.index, + assert_node(doctree, (addnodes.index, addnodes.index, + nodes.target, [desc, ([desc_signature, ([desc_annotation, ("class", desc_sig_space)], [desc_addname, "example."], [desc_name, "Class"])], @@ -1134,9 +1134,9 @@ def test_info_field_list_piped_type(app): doctree = restructuredtext.parse(app, text) assert_node(doctree, - (nodes.target, - addnodes.index, + (addnodes.index, addnodes.index, + nodes.target, [desc, ([desc_signature, ([desc_annotation, ("class", desc_sig_space)], [desc_addname, "example."], [desc_name, "Class"])], @@ -1168,9 +1168,9 @@ def test_info_field_list_Literal(app): doctree = restructuredtext.parse(app, text) assert_node(doctree, - (nodes.target, - addnodes.index, + (addnodes.index, addnodes.index, + nodes.target, [desc, ([desc_signature, ([desc_annotation, ("class", desc_sig_space)], [desc_addname, "example."], [desc_name, "Class"])], diff --git a/tests/test_transforms_reorder_nodes.py b/tests/test_transforms_reorder_nodes.py new file mode 100644 index 00000000000..7ffdae6c613 --- /dev/null +++ b/tests/test_transforms_reorder_nodes.py @@ -0,0 +1,96 @@ +"""Tests the transformations""" + +from docutils import nodes + +from sphinx import addnodes +from sphinx.testing import restructuredtext +from sphinx.testing.util import assert_node + + +def test_transforms_reorder_consecutive_target_and_index_nodes_preserve_order(app): + text = ('.. index:: abc\n' + '.. index:: def\n' + '.. index:: ghi\n' + '.. index:: jkl\n' + '\n' + 'text\n') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + addnodes.index, + addnodes.index, + addnodes.index, + nodes.target, + nodes.target, + nodes.target, + nodes.target, + nodes.paragraph)) + assert_node(doctree[0], addnodes.index, entries=[('single', 'abc', 'index-0', '', None)]) + assert_node(doctree[1], addnodes.index, entries=[('single', 'def', 'index-1', '', None)]) + assert_node(doctree[2], addnodes.index, entries=[('single', 'ghi', 'index-2', '', None)]) + assert_node(doctree[3], addnodes.index, entries=[('single', 'jkl', 'index-3', '', None)]) + assert_node(doctree[4], nodes.target, refid='index-0') + assert_node(doctree[5], nodes.target, refid='index-1') + assert_node(doctree[6], nodes.target, refid='index-2') + assert_node(doctree[7], nodes.target, refid='index-3') + # assert_node(doctree[8], nodes.paragraph) + + +def test_transforms_reorder_consecutive_target_and_index_nodes_no_merge_across_other_nodes(app): + text = ('.. index:: abc\n' + '.. index:: def\n' + '\n' + 'text\n' + '\n' + '.. index:: ghi\n' + '.. index:: jkl\n' + '\n' + 'text\n') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + addnodes.index, + nodes.target, + nodes.target, + nodes.paragraph, + addnodes.index, + addnodes.index, + nodes.target, + nodes.target, + nodes.paragraph)) + assert_node(doctree[0], addnodes.index, entries=[('single', 'abc', 'index-0', '', None)]) + assert_node(doctree[1], addnodes.index, entries=[('single', 'def', 'index-1', '', None)]) + assert_node(doctree[2], nodes.target, refid='index-0') + assert_node(doctree[3], nodes.target, refid='index-1') + # assert_node(doctree[4], nodes.paragraph) + assert_node(doctree[5], addnodes.index, entries=[('single', 'ghi', 'index-2', '', None)]) + assert_node(doctree[6], addnodes.index, entries=[('single', 'jkl', 'index-3', '', None)]) + assert_node(doctree[7], nodes.target, refid='index-2') + assert_node(doctree[8], nodes.target, refid='index-3') + # assert_node(doctree[9], nodes.paragraph) + + +def test_transforms_reorder_consecutive_target_and_index_nodes_merge_with_labels(app): + text = ('.. _abc:\n' + '.. index:: def\n' + '.. _ghi:\n' + '.. index:: jkl\n' + '.. _mno:\n' + '\n' + 'Heading\n' + '=======\n') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (nodes.title, + addnodes.index, + addnodes.index, + nodes.target, + nodes.target, + nodes.target, + nodes.target, + nodes.target)) + # assert_node(doctree[8], nodes.title) + assert_node(doctree[1], addnodes.index, entries=[('single', 'def', 'index-0', '', None)]) + assert_node(doctree[2], addnodes.index, entries=[('single', 'jkl', 'index-1', '', None)]) + assert_node(doctree[3], nodes.target, refid='abc') + assert_node(doctree[4], nodes.target, refid='index-0') + assert_node(doctree[5], nodes.target, refid='ghi') + assert_node(doctree[6], nodes.target, refid='index-1') + assert_node(doctree[7], nodes.target, refid='mno')