Repository: sphinx-doc/sphinx

Issue:
:rfc: should format links to sections
When you do

```rst
:rfc:`7231#section-6.6.1`
```

it's rendered like this:

[RFC 7231#section-6.6.1](https://tools.ietf.org/html/rfc7231.html#section-6.6.1)

Instead, it should figure out that it's a section link and rendered it as something nicer:

[RFC 7231, Section 6.6.1](https://tools.ietf.org/html/rfc7231.html#section-6.6.1)

Hints from issue comments:
@davidfstr: An example from an upcoming Python documentation change where improved "RFC section link rendering" would be nice: https://github.com/python/cpython/pull/102328#issue-1603178833

In that issue I suggest that

```rst
:rfc:`2616#section-5.1.2`
```

be formatted like **"RFC 2616 §5.1.2"**@davidfstr: It is possible to workaround the section formatting issue by providing the exact text of the link explicitly:

```rst
:rfc:`RFC 2616 §5.1.2 <2616#section-5.1.2>`
```@jstasiak: See https://github.com/sphinx-doc/sphinx/pull/11809 which implements this

Solution patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index ababf23e98e..a0acf5227ab 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -64,6 +64,8 @@ Features added
 * #11781: Add roles for referencing CVEs (:rst:role:`:cve: <cve>`)
   and CWEs (:rst:role:`:cwe: <cwe>`).
   Patch by Hugo van Kemenade.
+* #11809: Improve the formatting for RFC section anchors.
+  Patch by Jakub Stasiak and Adam Turner.
 
 Bugs fixed
 ----------
diff --git a/sphinx/roles.py b/sphinx/roles.py
index 9d4b407665d..27ceed29532 100644
--- a/sphinx/roles.py
+++ b/sphinx/roles.py
@@ -332,7 +332,8 @@ def build_uri(self) -> str:
 class RFC(ReferenceRole):
     def run(self) -> tuple[list[Node], list[system_message]]:
         target_id = 'index-%s' % self.env.new_serialno('index')
-        entries = [('single', 'RFC; RFC %s' % self.target, target_id, '', None)]
+        formatted_target = _format_rfc_target(self.target)
+        entries = [('single', f'RFC; {formatted_target}', target_id, '', None)]
 
         index = addnodes.index(entries=entries)
         target = nodes.target('', '', ids=[target_id])
@@ -346,7 +347,7 @@ def run(self) -> tuple[list[Node], list[system_message]]:
             if self.has_explicit_title:
                 reference += nodes.strong(self.title, self.title)
             else:
-                title = 'RFC ' + self.title
+                title = formatted_target
                 reference += nodes.strong(title, title)
         except ValueError:
             msg = self.inliner.reporter.error(
@@ -366,6 +367,24 @@ def build_uri(self) -> str:
             return base_url + self.inliner.rfc_url % int(ret[0])
 
 
+def _format_rfc_target(target: str, /) -> str:
+    """
+    Takes an RFC number with an optional anchor (like ``123#section-2.5.3``)
+    and attempts to produce a human-friendly title for it.
+
+    We have a set of known anchors that we format nicely,
+    everything else we leave alone.
+    """
+    number, _, anchor = target.partition('#')
+    if anchor:
+        first, _, remaining = anchor.partition('-')
+        if first in {'appendix', 'page', 'section'}:
+            if remaining:
+                return f'RFC {number} {first.title()} {remaining}'
+            return f'RFC {number} {first.title()}'
+    return f'RFC {target}'
+
+
 class GUILabel(SphinxRole):
     amp_re = re.compile(r'(?<!&)&(?![&\s])')
 


Test patch:
diff --git a/tests/test_markup/test_markup.py b/tests/test_markup/test_markup.py
index 1a045c6529f..98052de2572 100644
--- a/tests/test_markup/test_markup.py
+++ b/tests/test_markup/test_markup.py
@@ -278,17 +278,17 @@ def get(name):
         (
             # rfc role with anchor
             'verify',
-            ':rfc:`2324#id1`',
+            ':rfc:`2324#section-1`',
             (
                 '<p><span class="target" id="index-0"></span><a class="rfc reference external" '
-                'href="https://datatracker.ietf.org/doc/html/rfc2324.html#id1">'
-                '<strong>RFC 2324#id1</strong></a></p>'
+                'href="https://datatracker.ietf.org/doc/html/rfc2324.html#section-1">'
+                '<strong>RFC 2324 Section 1</strong></a></p>'
             ),
             (
                 '\\sphinxAtStartPar\n'
-                '\\index{RFC@\\spxentry{RFC}!RFC 2324\\#id1@\\spxentry{RFC 2324\\#id1}}'
-                '\\sphinxhref{https://datatracker.ietf.org/doc/html/rfc2324.html\\#id1}'
-                '{\\sphinxstylestrong{RFC 2324\\#id1}}'
+                '\\index{RFC@\\spxentry{RFC}!RFC 2324 Section 1@\\spxentry{RFC 2324 Section 1}}'
+                '\\sphinxhref{https://datatracker.ietf.org/doc/html/rfc2324.html\\#section-1}'
+                '{\\sphinxstylestrong{RFC 2324 Section 1}}'
             ),
         ),
         (
diff --git a/tests/test_roles.py b/tests/test_roles.py
index 2c7ea985be4..127f671a145 100644
--- a/tests/test_roles.py
+++ b/tests/test_roles.py
@@ -2,9 +2,10 @@
 
 from unittest.mock import Mock
 
+import pytest
 from docutils import nodes
 
-from sphinx.roles import EmphasizedLiteral
+from sphinx.roles import EmphasizedLiteral, _format_rfc_target
 from sphinx.testing.util import assert_node
 
 
@@ -113,3 +114,24 @@ def test_samp():
         ],
     )
     assert msg == []
+
+
+@pytest.mark.parametrize(
+    ('target', 'expected_output'),
+    [
+        ('123', 'RFC 123'),
+        ('123#', 'RFC 123#'),
+        ('123#id1', 'RFC 123#id1'),
+        ('123#section', 'RFC 123 Section'),
+        ('123#section-1', 'RFC 123 Section 1'),
+        ('123#section-2.5.3', 'RFC 123 Section 2.5.3'),
+        ('123#page-13', 'RFC 123 Page 13'),
+        ('123#appendix-B', 'RFC 123 Appendix B'),
+        # Section names are also present in some RFC anchors. Until we come up with a reliable way
+        # to reconstruct the section names from the corresponding anchors with the correct
+        # capitalization it's probably better to leave them alone.
+        ('9076#name-risks-in-the-dns-data', 'RFC 9076#name-risks-in-the-dns-data'),
+    ],
+)
+def test_format_rfc_target(target: str, expected_output: str) -> None:
+    assert _format_rfc_target(target) == expected_output