Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions sphinx/_extensions/_tests/test_inline_markup.py
Original file line number Diff line number Diff line change
@@ -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 ``<computeroutput>data_r</computeroutput>[]`` and
``<computeroutput><ref>k_msgq_init()</ref></computeroutput>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 = "<ref refid='group__msgq_1ga54' kindref='member' external='/t/api.tag'>k_msgq_init()</ref>"


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"<para>{xml}</para>"), **kw)


@pytest.mark.parametrize(
"xml, shown",
[
("All of <computeroutput>data_r</computeroutput>[] are gone.", "All of data_r[] are gone."),
("<computeroutput>data</computeroutput>[0..N-1] first", "data[0..N-1] first"),
(
"Once <computeroutput>" + REF + "</computeroutput>ed, wait.",
"Once k_msgq_init()ed, wait.",
),
("Once " + REF + "ed, wait.", "Once k_msgq_init()ed, wait."),
("re<computeroutput>x</computeroutput>", "rex"),
("<computeroutput>a</computeroutput><computeroutput>b</computeroutput>", "ab"),
("x=<computeroutput>a</computeroutput>+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 <computeroutput>f</computeroutput>, then (<computeroutput>g</computeroutput>).",
"Calls ``f``, then (``g``)."),
("Calls " + REF + ".", "Calls `k_msgq_init() <../api/group__msgq.html#ga54>`__."),
("Put <computeroutput>x</computeroutput> 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 <computeroutput>" + REF + "</computeroutput>ed")
assert rst == "Once :c:func:`k_msgq_init`\\ ed"


def test_plain_text_has_no_markup():
xml = ("Drain <computeroutput>data1</computeroutput>[] via <computeroutput>" + REF
+ "</computeroutput>, then " + REF + ".")
text = _text(xml, links=LINKS, plain=True)
assert text == "Drain data1[] via k_msgq_init(), then k_msgq_init()."
74 changes: 53 additions & 21 deletions sphinx/_scripts/doxygen_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <para>, 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 <para> 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(
Expand Down
4 changes: 2 additions & 2 deletions sphinx/_scripts/rst_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down