diff --git a/CHANGES.rst b/CHANGES.rst index b47f417e9a1..967dc112b57 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -15,6 +15,10 @@ Deprecated Features added -------------- +* #13076: Add the :confval:`autosectionlabel_full_reference` + configuration variable to create more precise labels. + Patch by Andrew Maguire + Bugs fixed ---------- diff --git a/doc/usage/extensions/autosectionlabel.rst b/doc/usage/extensions/autosectionlabel.rst index 1e9e1dba722..2ad9201a9a2 100644 --- a/doc/usage/extensions/autosectionlabel.rst +++ b/doc/usage/extensions/autosectionlabel.rst @@ -27,6 +27,10 @@ default. The ``autosectionlabel_prefix_document`` configuration variable can be used to make headings which appear multiple times but in different documents unique. +Use the :confval:`autosectionlabel_full_reference` configuration variable +to guarantee that the generated references are unique across a document +with similar section names at different levels. + Configuration ------------- @@ -50,6 +54,28 @@ Configuration only for top level sections, and deeper sections are not labeled. It defaults to ``None`` (i.e. all sections are labeled). +.. confval:: autosectionlabel_full_reference + :type: :code-py:`bool` + :default: :code-py:`False` + +.. versionadded:: 8.2 + + True to make each section label include all the parent sections separated by + a colon. For instance, if the following appears in document ``index.rst``: + + .. code-block:: rst + + Title + ===== + + Section + ------- + + Sub Section + ~~~~~~~~~~~ + + The reference of the third level section ("Sub Section") will be + ``index:Title:Section:Sub Section``. Debugging --------- diff --git a/sphinx/ext/autosectionlabel.py b/sphinx/ext/autosectionlabel.py index 63885e7039f..0ee747576a0 100644 --- a/sphinx/ext/autosectionlabel.py +++ b/sphinx/ext/autosectionlabel.py @@ -9,7 +9,7 @@ import sphinx from sphinx.locale import __ from sphinx.util import logging -from sphinx.util.nodes import clean_astext +from sphinx.util.nodes import clean_astext, make_id, traverse_parent if TYPE_CHECKING: from docutils.nodes import Node @@ -29,24 +29,58 @@ def get_node_depth(node: Node) -> int: return i -def register_sections_as_label(app: Sphinx, document: Node) -> None: +def _get_all_node_parent_section_titles(node: Node) -> list[str]: + parents = [] + for pnode in traverse_parent(node.parent, nodes.section): + title = cast(nodes.title, pnode[0]) + ref_name = getattr(title, 'rawsource', title.astext()) + parents.append(ref_name) + + return parents + + +def register_sections_as_label(app: Sphinx, doctree: nodes.document) -> None: domain = app.env.domains.standard_domain - for node in document.findall(nodes.section): + id_prefix = None + + settings = doctree.settings + for node in doctree.findall(nodes.section): if (app.config.autosectionlabel_maxdepth and get_node_depth(node) >= app.config.autosectionlabel_maxdepth): continue + labelid = node['ids'][0] docname = app.env.docname title = cast(nodes.title, node[0]) ref_name = getattr(title, 'rawsource', title.astext()) + if app.config.autosectionlabel_prefix_document: - name = nodes.fully_normalize_name(docname + ':' + ref_name) + if app.config.autosectionlabel_full_reference: + id_array = _get_all_node_parent_section_titles(node) + id_array = [docname, *reversed(id_array), ref_name] + # replace id_prefix temporarily + id_prefix = settings.id_prefix + settings.id_prefix = '.'.join(id_array) + + labelid = make_id(app.env, doctree, '', '.'.join(id_array)) + doctree.ids[labelid] = labelid + name = nodes.fully_normalize_name(labelid.replace('.', ':')) + + # restore id_prefix + settings.id_prefix = id_prefix + + # Add labelid as another reference id + # Note, cannot replace as this breaks TOC and sectnum functionality. + node['ids'].append(labelid) + else: + name = nodes.fully_normalize_name(f'{docname}:{ref_name}') else: name = nodes.fully_normalize_name(ref_name) + sectname = clean_astext(title) - logger.debug(__('section "%s" gets labeled as "%s"'), - ref_name, name, + logger.debug(__('section "%s" is: labeled "%s", id "%s", prefix "%s"'), + ref_name, name, labelid, id_prefix, location=node, type='autosectionlabel', subtype=docname) if name in domain.labels: logger.warning(__('duplicate label %s, other instance in %s'), @@ -58,8 +92,9 @@ def register_sections_as_label(app: Sphinx, document: Node) -> None: def setup(app: Sphinx) -> ExtensionMetadata: - app.add_config_value('autosectionlabel_prefix_document', False, 'env') + app.add_config_value('autosectionlabel_prefix_document', False, 'env', bool) app.add_config_value('autosectionlabel_maxdepth', None, 'env') + app.add_config_value('autosectionlabel_full_reference', False, 'env', bool) app.connect('doctree-read', register_sections_as_label) return { diff --git a/sphinx/util/nodes.py b/sphinx/util/nodes.py index 7f06ae194fc..c2642b6a10c 100644 --- a/sphinx/util/nodes.py +++ b/sphinx/util/nodes.py @@ -597,7 +597,19 @@ def make_id( node_id = None # fallback to None while node_id is None or node_id in document.ids: - node_id = idformat % env.new_serialno(prefix) + node_id = _make_id(idformat % env.new_serialno(prefix)) + + logger.debug( + __( + 'NODE "%s" gets term "%s" with ID format "%s" and prefix is "%s" and ids is "s"' + ), + node_id, + term, + idformat, + prefix, # document.ids, + location=document, + type='make_id', + ) return node_id diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index c98135efa7f..0966b85a4c6 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -490,7 +490,9 @@ def astext(self) -> str: return self.render('latex.tex.jinja', self.elements) def hypertarget(self, id: str, withdoc: bool = True, anchor: bool = True) -> str: - if withdoc: + if getattr(self.config, 'autosectionlabel_full_reference', False): + pass + elif withdoc: id = self.curfilestack[-1] + ':' + id escaped_id = self.idescape(id) return (r'\phantomsection' if anchor else '') + r'\label{%s}' % escaped_id @@ -1971,9 +1973,12 @@ def visit_reference(self, node: Element) -> None: if hashindex == -1: # reference to the document id = uri[1:] + '::doc' + elif getattr(self.config, 'autosectionlabel_full_reference', False): + id = uri[hashindex + 1 :] else: # reference to a label id = uri[1:].replace('#', ':') + self.body.append(self.hyperlink(id)) if ( len(node) diff --git a/sphinx/writers/texinfo.py b/sphinx/writers/texinfo.py index 997561b16fd..ef51b079515 100644 --- a/sphinx/writers/texinfo.py +++ b/sphinx/writers/texinfo.py @@ -550,7 +550,8 @@ def get_short_id(self, id: str) -> str: def add_anchor(self, id: str, node: Node) -> None: if id.startswith('index-'): return - id = self.curfilestack[-1] + ':' + id + if not getattr(self.config, 'autosectionlabel_full_reference', False): + id = self.curfilestack[-1] + ':' + id eid = self.escape_id(id) sid = self.get_short_id(id) for id in (eid, sid): diff --git a/tests/roots/test-ext-autosectionlabel-full-reference/conf.py b/tests/roots/test-ext-autosectionlabel-full-reference/conf.py new file mode 100644 index 00000000000..365ca3a35e7 --- /dev/null +++ b/tests/roots/test-ext-autosectionlabel-full-reference/conf.py @@ -0,0 +1,6 @@ +extensions = ['sphinx.ext.autosectionlabel'] +autosectionlabel_prefix_document = True +autosectionlabel_full_reference = True +latex_documents = [ + ('index', 'test.tex', '', 'Sphinx', 'report') +] diff --git a/tests/roots/test-ext-autosectionlabel-full-reference/index.rst b/tests/roots/test-ext-autosectionlabel-full-reference/index.rst new file mode 100644 index 00000000000..c4e4e4c992b --- /dev/null +++ b/tests/roots/test-ext-autosectionlabel-full-reference/index.rst @@ -0,0 +1,49 @@ +Introduction of Sphinx +====================== + + +Directives +---------- + + +Installation +============ + +.. include:: windows.rst + +For UNIX users +-------------- + +Linux +^^^^^ + +Command1 +'''''''' + +FreeBSD +^^^^^^^ + +2nd Command +''''''''''' + +This one's got an apostrophe +---------------------------- + + +References +========== + +* :ref:`index:Introduction-of-Sphinx` +* :ref:`index:Installation` +* :ref:`index:Installation:For-Windows-users` +* :ref:`index:Installation:For-Windows-users:Windows` +* :ref:`index:Installation:For-Windows-users:Windows:Command` +* :ref:`index:Installation:For-Windows-users:Windows:Command0` +* :ref:`index:Installation:For-Windows-users:Windows:Command1` +* :ref:`index:Installation:For-UNIX-users` +* :ref:`index:Installation:For-UNIX-users:Linux` +* :ref:`index:Installation:For-UNIX-users:Linux:Command1` +* :ref:`index:Installation:For-UNIX-users:FreeBSD` +* :ref:`index:Installation:For-UNIX-users:FreeBSD:2nd-Command` +* :ref:`index:Installation:This-one-s-got-an-apostrophe` + diff --git a/tests/roots/test-ext-autosectionlabel-full-reference/windows.rst b/tests/roots/test-ext-autosectionlabel-full-reference/windows.rst new file mode 100644 index 00000000000..70286c32104 --- /dev/null +++ b/tests/roots/test-ext-autosectionlabel-full-reference/windows.rst @@ -0,0 +1,27 @@ +For Windows users +----------------- + +Windows +^^^^^^^ + +Command +''''''' + +Command +''''''' + +This has the same name as the section before but a unique label id is required. + +Command +''''''' + +This has the same name as the two sections before but a unique label id is required. + +Local References +^^^^^^^^^^^^^^^^ + +* :ref:`windows:For-Windows-users` +* :ref:`windows:For-Windows-users:Windows` +* :ref:`windows:For-Windows-users:Windows:Command` +* :ref:`windows:For-Windows-users:Windows:Command0` +* :ref:`windows:For-Windows-users:Windows:Command1` diff --git a/tests/test_extensions/test_ext_autosectionlabel.py b/tests/test_extensions/test_ext_autosectionlabel.py index 2133f64bfaf..923a9ac2f9c 100644 --- a/tests/test_extensions/test_ext_autosectionlabel.py +++ b/tests/test_extensions/test_ext_autosectionlabel.py @@ -1,6 +1,7 @@ """Test sphinx.ext.autosectionlabel extension.""" import re +from pathlib import Path import pytest @@ -56,6 +57,31 @@ def test_autosectionlabel_html(app, skipped_labels=False): assert re.search(html, content, re.DOTALL) +@pytest.mark.sphinx('texinfo', testroot='ext-autosectionlabel') +def test_autosectionlabel_texinfo(app): + app.build(force_all=True) + + content = (app.outdir / 'projectnamenotset.texi').read_text(encoding='utf8') + + texinfo = ( + '@node Installation,References,Introduce of Sphinx,Top' + '\n' + '@anchor{index installation}@anchor{3}' + '\n' + '@chapter Installation' + ) + assert texinfo in content + + texinfo = ( + '@node For Windows users,For UNIX users,,Installation' + '\n' + '@anchor{index for-windows-users}@anchor{4}' + '\n' + '@section For Windows users' + ) + assert texinfo in content + + # Reuse test definition from above, just change the test root directory @pytest.mark.sphinx('html', testroot='ext-autosectionlabel-prefix-document') def test_autosectionlabel_prefix_document_html(app): @@ -98,3 +124,291 @@ def test_autosectionlabel_maxdepth(app): assert re.search(html, content, re.DOTALL) assert "WARNING: undefined label: 'linux'" in app.warning.getvalue() + + +@pytest.mark.sphinx('html', testroot='ext-autosectionlabel-full-reference') +def test_autosectionlabel_full_reference(app): + app.build(force_all=True) + + _autosectionlabel_full_reference_html_index(app.outdir / 'index.html') + _autosectionlabel_full_reference_html_windows(app.outdir / 'windows.html') + + +def _autosectionlabel_full_reference_html_index(file: Path) -> None: + content = file.read_text(encoding='utf8') + + html = ( + '

' + 'Introduction of Sphinx

' + 'Introduction of Sphinx

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Installation

' + 'Installation

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'For Windows users

' + 'For Windows users

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Windows

' + 'Windows

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Command

' + 'Command

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Command

' + 'Command

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Command

' + 'Command

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'For UNIX users

' + 'For UNIX users

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Linux

' + 'Linux

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Command1

' + 'Command1

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'FreeBSD

' + 'FreeBSD

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + '2nd Command

' + '2nd Command

' + ) + assert re.search(html, content, re.DOTALL) + + # for smart_quotes (refs: #4027) + html = ( + '

' + 'This one’s got an apostrophe

' + 'This one’s got an apostrophe' + '

' + ) + assert re.search(html, content, re.DOTALL) + + +def _autosectionlabel_full_reference_html_windows(file: Path) -> None: + content = file.read_text(encoding='utf8') + + html = ( + '

' + 'For Windows users

' + 'For Windows users

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Windows

' + 'Windows

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Command

' + 'Command

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Command

' + 'Command

' + ) + assert re.search(html, content, re.DOTALL) + + html = ( + '

' + 'Command

' + 'Command

' + ) + assert re.search(html, content, re.DOTALL) + + +@pytest.mark.sphinx('latex', testroot='ext-autosectionlabel-full-reference') +def test_autosectionlabel_full_reference_latex(app): + app.build(force_all=True) + + content = (app.outdir / 'test.tex').read_text(encoding='utf8') + content = content.replace('\\', '.').replace('[', '.').replace(']', '.') + + latex = ( + 'chapter{Installation}' + '.*' + '.label{.detokenize{index.Installation}}' + '.*' + '{.hyperref..detokenize{index.Installation}.{.sphinxcrossref{.DUrole{std}{.DUrole{std-ref}{Installation}}}}}' + ) + assert re.search(latex, content, re.DOTALL) + + latex = ( + 'subsubsection{Command}' + '.*' + '.label{.detokenize{index.Installation.For-Windows-users.Windows.Command}}' + '.*' + '{.hyperref..detokenize{index.Installation.For-Windows-users.Windows.Command}.' + '{.sphinxcrossref{.DUrole{std}{.DUrole{std-ref}{Command}}}}}' + ) + assert re.search(latex, content, re.DOTALL) + + latex = ( + 'subsubsection{Command}' + '.*' + '.label{.detokenize{index.Installation.For-Windows-users.Windows.Command0}}' + '.*' + '{.hyperref..detokenize{index.Installation.For-Windows-users.Windows.Command0}.' + '{.sphinxcrossref{.DUrole{std}{.DUrole{std-ref}{Command}}}}}' + ) + assert re.search(latex, content, re.DOTALL) + + latex = ( + 'subsubsection{Command}' + '.*' + '.label{.detokenize{index.Installation.For-Windows-users.Windows.Command1}}' + '.*' + '{.hyperref..detokenize{index.Installation.For-Windows-users.Windows.Command1}.' + '{.sphinxcrossref{.DUrole{std}{.DUrole{std-ref}{Command}}}}}' + ) + assert re.search(latex, content, re.DOTALL) + + +@pytest.mark.sphinx('texinfo', testroot='ext-autosectionlabel-full-reference') +def test_autosectionlabel_full_reference_texinfo(app): + app.build(force_all=True) + + content = (app.outdir / 'projectnamenotset.texi').read_text(encoding='utf8') + + texinfo = ( + '@node Installation,References,Directives,Top' + '\n' + '@anchor{index Installation}@anchor{5}@anchor{installation}@anchor{6}' + '\n' + '@unnumbered Installation' + ) + assert texinfo in content + + texinfo = ( + '@node Command,Command<2>,,Windows' + '\n' + '@anchor{command}@anchor{b}@anchor{index Installation For-Windows-users Windows Command}@anchor{c}' + '\n' + '@subsection Command' + ) + assert texinfo in content + + texinfo = ( + '@node Command<2>,Command<3>,Command,Windows' + '\n' + '@anchor{id1}@anchor{d}@anchor{index Installation For-Windows-users Windows Command0}@anchor{e}' + '\n' + '@subsection Command' + ) + assert texinfo in content + + texinfo = ( + '@node Command<3>,,Command<2>,Windows' + '\n' + '@anchor{id2}@anchor{f}@anchor{index Installation For-Windows-users Windows Command1}@anchor{10}' + '\n' + '@subsection Command' + ) + assert texinfo in content