Skip to content

Commit

Permalink
Add the :no-typesetting: option for only creating targets (#10478)
Browse files Browse the repository at this point in the history
Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
  • Loading branch information
latosha-maltba and AA-Turner committed Jul 28, 2023
1 parent 05a14ff commit 97d2c5d
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 19 deletions.
3 changes: 3 additions & 0 deletions CHANGES
Expand Up @@ -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
----------
Expand Down
24 changes: 24 additions & 0 deletions doc/usage/restructuredtext/domains.rst
Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions sphinx/directives/__init__.py
Expand Up @@ -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
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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]


Expand Down
1 change: 1 addition & 0 deletions sphinx/domains/c.py
Expand Up @@ -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,
}

Expand Down
1 change: 1 addition & 0 deletions sphinx/domains/cpp.py
Expand Up @@ -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,
}
Expand Down
9 changes: 6 additions & 3 deletions sphinx/domains/javascript.py
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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]:
Expand All @@ -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

Expand Down
5 changes: 4 additions & 1 deletion sphinx/domains/python.py
Expand Up @@ -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,
Expand Down Expand Up @@ -1262,6 +1263,7 @@ class PyModule(SphinxDirective):
'synopsis': lambda x: x,
'noindex': directives.flag,
'nocontentsentry': directives.flag,
'no-typesetting': directives.flag,
'deprecated': directives.flag,
}

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions sphinx/domains/rst.py
Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions sphinx/transforms/__init__.py
Expand Up @@ -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::
<document>
<target ids="id1" ...>
<index entries="...1...">
<target ids="id2" ...>
<target ids="id3" ...>
<index entries="...2...">
<target ids="id4" ...>
The transformed result will be::
<document>
<index entries="...1...">
<index entries="...2...">
<target ids="id1" ...>
<target ids="id2" ...>
<target ids="id3" ...>
<target ids="id4" ...>
"""

# 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)
Expand All @@ -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',
Expand Down
108 changes: 108 additions & 0 deletions 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))
8 changes: 4 additions & 4 deletions tests/test_domain_js.py
Expand Up @@ -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):
Expand Down

0 comments on commit 97d2c5d

Please sign in to comment.