From 439f2b408c5710f6b7e712b3d61a1e5a54c03859 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sat, 10 Oct 2026 20:15:25 +0200 Subject: [PATCH 1/2] fix: sphinx: inline markup next to a word in Doxygen prose RST accepts inline markup only next to whitespace or some punctuation. Doxygen prose often puts code or a link directly against other text: "@p data_p[0..LIST_LEN-1]" gives a literal before "[", and "K_MSGQ_DEFINE()d" a link before "d". Both are valid Doxygen, but the RST showed the raw markup: in the Zephyr test specification, 93 "start-string without end-string" warnings. para_text now puts an escaped space between such markup and its neighbour. RST takes it as the boundary and removes it from the output. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- .../_extensions/_tests/test_inline_markup.py | 75 +++++++++++++++++++ sphinx/_scripts/doxygen_parser.py | 61 +++++++++++---- 2 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 sphinx/_extensions/_tests/test_inline_markup.py diff --git a/sphinx/_extensions/_tests/test_inline_markup.py b/sphinx/_extensions/_tests/test_inline_markup.py new file mode 100644 index 0000000..b8f3b06 --- /dev/null +++ b/sphinx/_extensions/_tests/test_inline_markup.py @@ -0,0 +1,75 @@ +# 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" + diff --git a/sphinx/_scripts/doxygen_parser.py b/sphinx/_scripts/doxygen_parser.py index 43dada8..f26cfcd 100644 --- a/sphinx/_scripts/doxygen_parser.py +++ b/sphinx/_scripts/doxygen_parser.py @@ -126,6 +126,40 @@ def elem_text(elem: ET.Element | None) -> str: return " ".join(" ".join(buf).split()) +#: 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) -> str: """Extract inline text from a , rendering code/ref as plain text. @@ -137,38 +171,33 @@ def para_text(para: ET.Element | None, links: RefLinks | None = None) -> str: (it is not parsed), so a caller building one passes no ``links``.""" 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 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)}``") + parts.append((f"``{elem_text(child)}``", True)) elif child.tag in ("bold", "emphasis"): - parts.append(elem_text(child)) + parts.append((elem_text(child), False)) 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( From 04160253c5cedd4e17fcf59371a968f16b1d556d Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sat, 10 Oct 2026 20:15:37 +0200 Subject: [PATCH 2/2] fix: sphinx: plain text in the procedure titles A test procedure need takes its title from the brief, and a need title is not parsed. The brief came from para_text as RST, so the title showed the markup: "drain ``fifo2`` via ISR". para_text(plain=True) gives code and references as their text. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- sphinx/_extensions/_tests/test_inline_markup.py | 6 ++++++ sphinx/_scripts/doxygen_parser.py | 15 +++++++++------ sphinx/_scripts/rst_builders.py | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/sphinx/_extensions/_tests/test_inline_markup.py b/sphinx/_extensions/_tests/test_inline_markup.py index b8f3b06..b156b43 100644 --- a/sphinx/_extensions/_tests/test_inline_markup.py +++ b/sphinx/_extensions/_tests/test_inline_markup.py @@ -73,3 +73,9 @@ 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 f26cfcd..8175d5f 100644 --- a/sphinx/_scripts/doxygen_parser.py +++ b/sphinx/_scripts/doxygen_parser.py @@ -160,21 +160,26 @@ def _join_inline(parts: list[tuple[str, bool]]) -> str: return " ".join(out.split()) -def para_text(para: ET.Element | None, links: RefLinks | None = None) -> str: - """Extract inline text from a , rendering code/ref as plain text. +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: 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)): @@ -183,8 +188,6 @@ def para_text(para: ET.Element | None, links: RefLinks | None = None) -> str: parts.append((f":c:func:`{(ref.text or '').rstrip('()').strip()}`", True)) else: parts.append((f"``{elem_text(child)}``", True)) - elif child.tag in ("bold", "emphasis"): - parts.append((elem_text(child), False)) elif child.tag == "ref": kindref = child.get("kindref", "") t = (child.text or "").strip() 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