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