diff --git a/sphinx/_extensions/_tests/test_inline_markup.py b/sphinx/_extensions/_tests/test_inline_markup.py
new file mode 100644
index 0000000..b156b43
--- /dev/null
+++ b/sphinx/_extensions/_tests/test_inline_markup.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2026 inovex GmbH
+#
+# SPDX-License-Identifier: Apache-2.0
+
+"""Doxygen prose becomes RST that docutils parses as written.
+
+RST accepts inline markup only next to whitespace or some punctuation. The
+test specification writes ``data_r[]`` and
+``[k_msgq_init()]ed``; joined as
+written, the page showed the backticks and the build warned "Inline literal
+start-string without end-string".
+"""
+
+import xml.etree.ElementTree as ET
+
+import doxygen_parser as dp
+import pytest
+from docutils import nodes
+from docutils.core import publish_doctree
+
+LINKS = dp.RefLinks(api="../api", local="../testspec")
+REF = "[k_msgq_init()]"
+
+
+def _parse(rst):
+ """The doctree of ``rst`` and the system messages docutils wrote for it."""
+ tree = publish_doctree(rst, settings_overrides={"report_level": 5, "halt_level": 5})
+ return tree, list(tree.findall(nodes.system_message))
+
+
+def _text(xml, **kw):
+ return dp.para_text(ET.fromstring(f"{xml}"), **kw)
+
+
+@pytest.mark.parametrize(
+ "xml, shown",
+ [
+ ("All of data_r[] are gone.", "All of data_r[] are gone."),
+ ("data[0..N-1] first", "data[0..N-1] first"),
+ (
+ "Once " + REF + "ed, wait.",
+ "Once k_msgq_init()ed, wait.",
+ ),
+ ("Once " + REF + "ed, wait.", "Once k_msgq_init()ed, wait."),
+ ("rex", "rex"),
+ ("ab", "ab"),
+ ("x=a+2", "x=a+2"),
+ ],
+)
+def test_markup_next_to_a_word_parses(xml, shown):
+ rst = _text(xml, links=LINKS)
+ tree, messages = _parse(rst)
+ assert messages == []
+ assert tree.astext() == shown
+ assert "`" not in tree.astext()
+
+
+@pytest.mark.parametrize(
+ "xml, rst",
+ [
+ # Space and punctuation are already a boundary: no escape goes in.
+ ("Calls f, then (g).",
+ "Calls ``f``, then (``g``)."),
+ ("Calls " + REF + ".", "Calls `k_msgq_init() <../api/group__msgq.html#ga54>`__."),
+ ("Put x in", "Put ``x`` in"),
+ ],
+)
+def test_markup_at_a_boundary_is_unchanged(xml, rst):
+ assert _text(xml, links=LINKS) == rst
+
+
+def test_without_links_the_role_is_escaped_too():
+ rst = _text("Once " + REF + "ed")
+ assert rst == "Once :c:func:`k_msgq_init`\\ ed"
+
+
+def test_plain_text_has_no_markup():
+ xml = ("Drain data1[] via " + REF
+ + ", then " + REF + ".")
+ text = _text(xml, links=LINKS, plain=True)
+ assert text == "Drain data1[] via k_msgq_init(), then k_msgq_init()."
diff --git a/sphinx/_scripts/doxygen_parser.py b/sphinx/_scripts/doxygen_parser.py
index 43dada8..8175d5f 100644
--- a/sphinx/_scripts/doxygen_parser.py
+++ b/sphinx/_scripts/doxygen_parser.py
@@ -126,49 +126,81 @@ def elem_text(elem: ET.Element | None) -> str:
return " ".join(" ".join(buf).split())
-def para_text(para: ET.Element | None, links: RefLinks | None = None) -> str:
- """Extract inline text from a , rendering code/ref as plain text.
+#: The characters that can come directly before an RST inline markup
+#: start-string, and directly after an end-string, besides whitespace
+#: (docutils ``punctuation_chars``, ASCII only). A backslash before a
+#: start-string escapes it, so it is not in the first set.
+_RST_BEFORE_START = frozenset("\"'(-/:<[{")
+_RST_AFTER_END = frozenset("!\"'),-./:;>?\\]}")
+
+
+def _join_inline(parts: list[tuple[str, bool]]) -> str:
+ """Join ``(text, is_markup)`` parts as RST, then collapse whitespace runs.
+
+ RST accepts inline markup only next to whitespace or some punctuation, so
+ a literal directly before ``[]`` ("data_r[]") or a link directly before
+ ``ed`` ("k_msgq_init()ed") shows the raw markup. Where a markup part has
+ another neighbour, an escaped space (``\\ ``) goes between them: RST takes
+ it as the boundary and removes it from the output.
+ """
+ out = ""
+ after_markup = False
+ for text, markup in parts:
+ if not text:
+ continue
+ if out and (
+ (markup and not (out[-1].isspace() or out[-1] in _RST_BEFORE_START))
+ or (after_markup and not (text[0].isspace() or text[0] in _RST_AFTER_END))
+ ):
+ out += "\\ "
+ out += text
+ after_markup = markup
+ # Joined as written, then whitespace runs collapsed. Joining with a space
+ # put one before the punctuation that follows a reference ("k_fifo_put() .").
+ return " ".join(out.split())
+
+
+def para_text(
+ para: ET.Element | None, links: RefLinks | None = None, plain: bool = False
+) -> str:
+ """Extract inline text from a as RST, or as plain text with ``plain``.
With ``links``, a symbol reference becomes a hyperlink into the Doxygen HTML
(see `ref_to_rst`) — the same target the see-also line links to. Without,
a member reference becomes a ``:c:func:`` role, which resolves only where a
C domain defines the symbol; in the test documents nothing does, so it
- renders as unlinked code, silently. The need title must stay plain text
- (it is not parsed), so a caller building one passes no ``links``."""
+ renders as unlinked code, silently. A need title is not parsed, so a
+ caller building one asks for ``plain`` text: code and references as their
+ text, no markup."""
if para is None:
return ""
- parts = []
- if para.text:
- parts.append(para.text)
+ parts: list[tuple[str, bool]] = [(para.text or "", False)]
for child in para:
if child.tag in ("xrefsect", "simplesect", "orderedlist", "itemizedlist", "parameterlist"):
pass # skip structural children
+ elif plain or child.tag in ("bold", "emphasis"):
+ parts.append((elem_text(child), False))
elif child.tag == "computeroutput":
ref = child.find("ref[@kindref='member']")
if ref is not None and (link := ref_to_rst(ref, links)):
- parts.append(link)
+ parts.append((link, True))
elif ref is not None:
- parts.append(f":c:func:`{(ref.text or '').rstrip('()').strip()}`")
+ parts.append((f":c:func:`{(ref.text or '').rstrip('()').strip()}`", True))
else:
- parts.append(f"``{elem_text(child)}``")
- elif child.tag in ("bold", "emphasis"):
- parts.append(elem_text(child))
+ parts.append((f"``{elem_text(child)}``", True))
elif child.tag == "ref":
kindref = child.get("kindref", "")
t = (child.text or "").strip()
if link := ref_to_rst(child, links):
- parts.append(link)
+ parts.append((link, True))
elif kindref == "member" and t:
- parts.append(f":c:func:`{t.rstrip('()').strip()}`")
+ parts.append((f":c:func:`{t.rstrip('()').strip()}`", True))
else:
- parts.append(elem_text(child))
+ parts.append((elem_text(child), False))
else:
- parts.append(elem_text(child))
- if child.tail:
- parts.append(child.tail)
- # Joined as written, then whitespace runs collapsed. Joining with a space
- # put one before the punctuation that follows a reference ("k_fifo_put() .").
- return " ".join("".join(parts).split())
+ parts.append((elem_text(child), False))
+ parts.append((child.tail or "", False))
+ return _join_inline(parts)
def list_to_rst_lines(
diff --git a/sphinx/_scripts/rst_builders.py b/sphinx/_scripts/rst_builders.py
index af25630..e800043 100644
--- a/sphinx/_scripts/rst_builders.py
+++ b/sphinx/_scripts/rst_builders.py
@@ -219,8 +219,8 @@ def build_procedure_need_rst(
name = memberdef.findtext("name", "").strip()
need_id = f"test-proc-{proc_group_name}-{name}"
- # No links here: the brief is only used as the title, which is not parsed.
- brief = para_text(memberdef.find(".//briefdescription/para"))
+ # Plain: the brief is only used as the title, which is not parsed.
+ brief = para_text(memberdef.find(".//briefdescription/para"), plain=True)
title = (brief[:90] + "…") if len(brief) > 90 else brief
if not title:
title = name