diff --git a/cmake/check_doxygen_warnings.cmake b/cmake/check_doxygen_warnings.cmake new file mode 100644 index 0000000..5a091b1 --- /dev/null +++ b/cmake/check_doxygen_warnings.cmake @@ -0,0 +1,72 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 +# +# Build-time gate over a Doxygen warning log. +# +# Invoked as `cmake -P check_doxygen_warnings.cmake` right after stage 2's +# Doxygen run, from the same custom target in doxygen.cmake. +# +# Required -D arguments: +# DOC_ID registry id of the document, for the messages +# LOGFILE the WARN_LOGFILE stage 2 wrote +# PATTERNS ;-separated regular expressions; a log line matching any of them +# fails the build. Empty: the log is echoed and nothing fails. + +#[==[.rst: +check_doxygen_warnings.cmake +============================ + +Internal ``cmake -P`` gate run after :cmake:command:`add_doxygen_target`'s +stage-2 Doxygen build. Stage 2 writes its warnings to a log file instead of the +console; this script echoes that log, so the console shows what it always did, +then fails the target if any line matches one of +``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS``. + +Stage 2 only, deliberately. Stage 1 runs with ``TAGFILES`` cleared, so every +reference into a peer document — including every ``\verifies`` and +``\satisfies`` against another project's ``\requirement`` — warns there +falsely; stage 1 is therefore silenced and never gated. + +The gate exists because the Doxygen XML cannot tell a real requirement link +from a typo: Doxygen synthesizes ```` +from the UID string whether or not any ``\requirement`` defines it. Its warning +is the only signal. +#]==] + +foreach(var DOC_ID LOGFILE) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "check_doxygen_warnings.cmake: missing required -D${var}") + endif() +endforeach() + +# Doxygen writes the log even when there is nothing to say; a missing one means +# it never ran this configuration, and doxygen's own failure is reported by +# whichever command ran it. +if(NOT EXISTS ${LOGFILE}) + return() +endif() + +file(READ ${LOGFILE} log) +if(NOT log STREQUAL "") + # To stderr, like doxygen itself; NOTICE adds no prefix and no call stack. + string(REGEX REPLACE "\n$" "" log "${log}") + message(NOTICE "${log}") +endif() + +if("${PATTERNS}" STREQUAL "") + return() +endif() + +string(REPLACE ";" "|" regex "${PATTERNS}") +file(STRINGS ${LOGFILE} hits REGEX "${regex}") +if(hits) + list(LENGTH hits count) + list(JOIN hits "\n " listing) + message( + FATAL_ERROR + "${DOC_ID}: ${count} Doxygen warning(s) match ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS:\n" + " ${listing}\n" + "Full log: ${LOGFILE}" + ) +endif() diff --git a/cmake/doxygen.cmake b/cmake/doxygen.cmake index 4c134b5..ff9f6f2 100644 --- a/cmake/doxygen.cmake +++ b/cmake/doxygen.cmake @@ -33,6 +33,16 @@ document's registry id, verbatim. # Doxygen. Requiring it here makes the failure say what it is, at configure time. find_package(Doxygen REQUIRED) +# Stage-2 warnings that fail the build (see check_doxygen_warnings.cmake). The +# default is the one warning that is always a defect and that nothing else +# catches: a `\verifies` / `\satisfies` naming a requirement no `\requirement` +# defines. Doxygen synthesizes the XML link from the UID either way, so a typo +# is otherwise indistinguishable from a real link. A consumer may add patterns, +# or set the list empty to gate nothing. +if(NOT DEFINED ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS) + set(ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS "Reference to unknown requirement") +endif() + #------------------------------------------------------------------------------- # Doxygen (standalone) # @@ -203,6 +213,15 @@ function(add_doxygen_target name) ) endif() + # Stage 2's warnings go to a log that check_doxygen_warnings.cmake echoes + # and gates; stage 1 resets it below. + set(DOXY_WARN_LOG ${CMAKE_CURRENT_BINARY_DIR}/${DOXY_DOC}.warnings.log) + file( + APPEND ${DOXYFILE_OUT} + "\n# --- WARN_LOGFILE (appended by zdocs/cmake/doxygen.cmake) ---\n" + "WARN_LOGFILE = ${DOXY_WARN_LOG}\n" + ) + # -- Inter-doxygen TAGFILES (registry-driven): let this doc resolve symbols # documented in the other doxygen docs (e.g. testspec -> api). Appended to the # GENERATED doxyfile rather than substituted into each Doxyfile.in. @@ -337,6 +356,8 @@ function(add_doxygen_target name) "WARN_NO_PARAMDOC = NO\n" "WARN_IF_UNDOC_ENUM_VAL = NO\n" "WARN_AS_ERROR = NO\n" + # Not stage 2's log: this run must neither overwrite nor feed its gate. + "WARN_LOGFILE =\n" ) # Doxygen is invoked through run_doxygen.cmake (a `cmake -P` wrapper) so a @@ -392,7 +413,15 @@ function(add_doxygen_target name) add_dependencies(doc-tags ${DOXY_DOC}-tag) add_dependencies(doc-index ${DOXY_DOC}-tag) - add_doc_target(${DOXY_DOC} COMMAND ${DOX_STAGE2_CMD} COMMENT "Running Doxygen for ${DOXY_DOC}...") + add_doc_target( + ${DOXY_DOC} + COMMAND ${DOX_STAGE2_CMD} + COMMAND + ${CMAKE_COMMAND} -DDOC_ID=${DOXY_DOC} -DLOGFILE=${DOXY_WARN_LOG} + "-DPATTERNS=${ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS}" + -P ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/check_doxygen_warnings.cmake + COMMENT "Running Doxygen for ${DOXY_DOC}..." + ) # Stage 2 waits for all stage-1 tags so TAGFILES cross references resolve. add_dependencies(${DOXY_DOC} doc-index) diff --git a/cmake/zdocs.cmake b/cmake/zdocs.cmake index e8c71af..a60cd39 100644 --- a/cmake/zdocs.cmake +++ b/cmake/zdocs.cmake @@ -65,6 +65,11 @@ list(APPEND CMAKE_MODULE_PATH ${ZDOCS_CMAKE_DIR}) # ZDOCS_PROJECT_LOGO (optional) logo for Doxygen pages. # ZDOCS_DOXYGEN_EXTRA_CSS (optional) brand stylesheets, appended after the # theme so they win. +# ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS +# (optional) regexes; a stage-2 Doxygen warning +# matching one fails that document's build. Defaults +# to Doxygen's "Reference to unknown requirement"; +# set it empty to gate nothing. # ZDOCS_DOC_BASE_URL (optional) base URL the deploy tree is served under, # used for absolute cross-document need links. # ZDOCS_SPHINX_EXTRA_ENV (optional) extra VAR=value entries passed to every diff --git a/doc/api/cmake/modules.rst b/doc/api/cmake/modules.rst index a84c145..bc5c501 100644 --- a/doc/api/cmake/modules.rst +++ b/doc/api/cmake/modules.rst @@ -19,3 +19,5 @@ surface — it is the bracket comments themselves, rendered. .. cmake-module:: /cmake/download_external_tag.cmake .. cmake-module:: /cmake/run_doxygen.cmake + +.. cmake-module:: /cmake/check_doxygen_warnings.cmake diff --git a/doc/manual/reference/consumer-contract.rst b/doc/manual/reference/consumer-contract.rst index 7a755e7..8b760bd 100644 --- a/doc/manual/reference/consumer-contract.rst +++ b/doc/manual/reference/consumer-contract.rst @@ -59,6 +59,16 @@ layout, not toggled from outside it. A list of stylesheet paths, appended to Doxygen's ``HTML_EXTRA_STYLESHEET`` after the engine's own theme — so your rules win. +``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS`` + A list of regular expressions. After each Doxygen document's stage-2 build, + a warning line matching any of them fails that document's target; the full + warning log is still printed either way. Defaults to + ``Reference to unknown requirement`` — a ``\verifies`` or ``\satisfies`` + naming a UID that no ``\requirement`` defines, which the XML cannot reveal + (Doxygen synthesizes the link from the UID regardless). Set it to an empty + string to gate nothing. Stage 1 is never gated: it runs with ``TAGFILES`` + cleared, so its cross-document warnings are false. + ``ZDOCS_SPHINX_EXTRA_ENV`` A list of ``VAR=value`` strings, spliced verbatim into every ``sphinx-build`` invocation's environment, for a ``conf.py`` that needs diff --git a/sphinx/_extensions/_tests/test_doxygen_parser.py b/sphinx/_extensions/_tests/test_doxygen_parser.py index 1e3548b..2308851 100644 --- a/sphinx/_extensions/_tests/test_doxygen_parser.py +++ b/sphinx/_extensions/_tests/test_doxygen_parser.py @@ -192,18 +192,26 @@ def test_see_to_rst_ref_unresolved(): # parse_memberdef # --------------------------------------------------------------------------- -def _make_memberdef(extra_xrefsects="", inbody=""): +def _make_memberdef(extra_xrefsects="", inbody="", member_extra=""): return ET.fromstring( f"" f"test_queue_put" f"Test queue put." f"{extra_xrefsects}" f"{inbody}" + f"{member_extra}" f"" f"" ) +def _verifies(*uids): + """Doxygen 1.16's native `\\verifies` output: a child of the + memberdef, one per UID, the UID only in its refid.""" + reqs = "".join(f"" for uid in uids) + return f"{reqs}" + + def test_parse_memberdef_extracts_testid(): xref = ( "" @@ -228,6 +236,57 @@ def test_parse_memberdef_extracts_reqrefs(): assert "zep-srs-20-1" in info["req_ids"] +def test_parse_memberdef_reads_autolinked_xrefitem_values(): + # With a needs tag in TAGFILES, Doxygen autolinks an identifier-shaped UID + # inside the xrefitem; the value must survive the wrapper. + xref = ( + "" + "Test ID" + "TC_ONE " + "" + "" + "" + "Requirement Refs" + "DUTY_001 " + "" + ) + md = _make_memberdef(extra_xrefsects=xref) + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["test_id"] == "TC_ONE" + assert info["req_ids"] == ["DUTY_001"] + + +def test_parse_memberdef_extracts_native_verifies(): + md = _make_memberdef(member_extra=_verifies("ZEP-SRS-20-6", "ZEP-SRS-20-7")) + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["req_ids"] == ["ZEP-SRS-20-6", "ZEP-SRS-20-7"] + + +def test_parse_memberdef_native_verifies_and_reqrefs_coexist(): + # Both paths are live during the migration (D2); a UID named by both is + # listed once. + xref = ( + "" + "Requirement Refs" + "ZEP-SRS-20-1" + "" + ) + md = _make_memberdef( + extra_xrefsects=xref, member_extra=_verifies("ZEP-SRS-20-1", "ZEP-SRS-20-2") + ) + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["req_ids"] == ["ZEP-SRS-20-1", "ZEP-SRS-20-2"] + + +def test_parse_memberdef_ignores_verifies_of_other_kinds(): + # Only children carry a UID; anything else Doxygen may put + # there is not a requirement link. + md = _make_memberdef(member_extra="x") + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["req_ids"] == [] + + def test_parse_memberdef_active_status(): xref = ( "" @@ -408,3 +467,60 @@ def test_extract_params_basic(): assert len(params) == 1 assert params[0][0] == "queue" assert "queue pointer" in params[0][1] + + +# --------------------------------------------------------------------------- +# symbol links in prose (brief, details, test steps, Arrange/Act/Assert) +# +# The see-also line linked a symbol into the Doxygen HTML; the same symbol in +# the prose became a :c:func: role, which nothing in the test documents can +# resolve, so it rendered as code with no link and no warning. +# --------------------------------------------------------------------------- + +API = "../api" +SPEC = "../testspec" +EXT_REF = ( + "k_fifo_get()" +) +LOCAL_REF = "get_scratch_packet()" + + +def test_para_text_links_external_symbol_into_api_doxygen(): + para = ET.fromstring(f"Call {EXT_REF} now.") + text = dp.para_text(para, links=dp.RefLinks(api=API, local=SPEC)) + assert "`k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__" in text + assert ":c:func:" not in text + + +def test_para_text_links_local_symbol_into_testspec_doxygen(): + para = ET.fromstring(f"Call {LOCAL_REF}.") + text = dp.para_text(para, links=dp.RefLinks(api=API, local=SPEC)) + assert "`get_scratch_packet() <../testspec/group__procs.html#ga3f93>`__" in text + + +def test_para_text_without_links_is_unchanged(): + para = ET.fromstring(f"Call {EXT_REF}.") + assert dp.para_text(para) == "Call :c:func:`k_fifo_get`." + + +def test_parse_memberdef_links_symbols_in_brief_details_and_body(): + md = ET.fromstring( + "test_x" + f"Verify {EXT_REF}." + f"Details {EXT_REF}." + "Act" + f"Call {EXT_REF}." + "" + "" + ) + info = dp.parse_memberdef(md, "group__s", SPEC, API) + link = "<../api/group__fifo__apis.html#ga1e2c>`__" + assert link in info["brief"] + assert any(link in line for line in info["detail_lines"]) + assert any(link in line for sect in info["body_sections"] for line in sect) + + +def test_see_to_rst_links_local_symbol_when_testspec_dir_given(): + see = ET.fromstring(f"{LOCAL_REF}") + assert "../testspec/group__procs.html#ga3f93" in dp.see_to_rst(see, API, SPEC) diff --git a/sphinx/_extensions/_tests/test_doxygen_warn_gate.py b/sphinx/_extensions/_tests/test_doxygen_warn_gate.py new file mode 100644 index 0000000..f1cefac --- /dev/null +++ b/sphinx/_extensions/_tests/test_doxygen_warn_gate.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""The stage-2 Doxygen warning gate fails only on the configured patterns.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +GATE = Path(__file__).resolve().parents[3] / "cmake" / "check_doxygen_warnings.cmake" +DEFAULT = "Reference to unknown requirement" + +TYPO = "src/main.c:467: warning: Reference to unknown requirement 'ZEP-SRS-24-' found" +UNDOC = "src/main.c:12: warning: Member foo (function) of file main.c is not documented." + +pytestmark = pytest.mark.skipif(shutil.which("cmake") is None, reason="needs cmake") + + +def run(tmp_path, lines, patterns=DEFAULT, write_log=True): + log = tmp_path / "doc.warnings.log" + if write_log: + log.write_text("".join(line + "\n" for line in lines)) + return subprocess.run( + ["cmake", "-DDOC_ID=doc", f"-DLOGFILE={log}", f"-DPATTERNS={patterns}", "-P", GATE], + capture_output=True, + text=True, + ) + + +def test_unknown_requirement_fails_and_names_the_line(tmp_path): + result = run(tmp_path, [UNDOC, TYPO]) + assert result.returncode != 0 + assert "1 Doxygen warning(s) match" in result.stderr + assert "'ZEP-SRS-24-'" in result.stderr + + +def test_other_warnings_pass_and_are_still_printed(tmp_path): + result = run(tmp_path, [UNDOC]) + assert result.returncode == 0 + assert UNDOC in result.stderr + + +def test_empty_patterns_gate_nothing(tmp_path): + result = run(tmp_path, [TYPO], patterns="") + assert result.returncode == 0 + assert TYPO in result.stderr + + +def test_any_of_several_patterns_fails(tmp_path): + result = run(tmp_path, [UNDOC], patterns=f"{DEFAULT};is not documented") + assert result.returncode != 0 + + +def test_missing_log_passes(tmp_path): + assert run(tmp_path, [], write_log=False).returncode == 0 diff --git a/sphinx/_extensions/_tests/test_rst_builders.py b/sphinx/_extensions/_tests/test_rst_builders.py index 570099f..3460820 100644 --- a/sphinx/_extensions/_tests/test_rst_builders.py +++ b/sphinx/_extensions/_tests/test_rst_builders.py @@ -194,3 +194,27 @@ def test_build_scenario_table_renders_list_table(tmp_path): def test_build_scenario_table_missing_yaml(): lines = rb.build_scenario_table(FIXTURES / "nonexistent.yaml") assert lines == [] + + +def test_build_procedure_need_rst_links_prose_but_keeps_title_plain(): + # A need's title is not parsed as RST, so link markup there would render + # verbatim; the details are parsed and link like the see-also line does. + import xml.etree.ElementTree as ET + ref = ( + "k_fifo_get()" + ) + memberdef = ET.fromstring( + "" + "drain" + f"Drain via {ref}." + f"Calls {ref} until empty." + "" + "" + ) + rst = rb.build_procedure_need_rst( + memberdef, "group__queue__procedures", "queue_procedures", "../testspec", "../api" + ) + title = rst.splitlines()[0] + assert "<" not in title and "k_fifo_get" in title + assert "`k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__ until empty" in rst diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index 2166287..b64faa2 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -5,10 +5,12 @@ """Doxygen XML parsing — no Sphinx dependency.""" import xml.etree.ElementTree as ET from pathlib import Path -from typing import TypedDict +from typing import NamedTuple, TypedDict __all__ = [ "MemberInfo", + "RefLinks", + "ref_to_rst", "elem_text", "para_text", "list_to_rst_lines", @@ -35,6 +37,41 @@ class MemberInfo(TypedDict): body_sections: list[list[str]] +class RefLinks(NamedTuple): + """Where a in Doxygen prose points, as HTML directory URLs. + + ``api`` for a symbol Doxygen resolved through a tag file (``external=``) — + the API document the test specification references; ``local`` for one + documented in the parsed project itself, such as a shared test procedure. + An empty string leaves that kind unlinked. + """ + + api: str = "" + local: str = "" + + +def ref_to_rst(ref: ET.Element, links: RefLinks | None) -> str | None: + """A as an RST hyperlink into the Doxygen HTML, or None if it has none. + + Doxygen's refid is ``_1`` for a member and the bare + compound for a page or group. The caller decides what an unlinked ref + becomes. + """ + name = (ref.text or "").strip() + refid = ref.get("refid", "") + if not (links and name and refid): + return None + base = links.api if ref.get("external") else links.local + if not base: + return None + if "_1" in refid: + idx = refid.rfind("_1") + url = f"{base}/{refid[:idx]}.html#{refid[idx + 2:]}" + else: + url = f"{base}/{refid}.html" + return f"`{name} <{url}>`__" + + def elem_text(elem: ET.Element | None) -> str: """Walk the element tree collecting all text nodes (.text and .tail), join them, then normalise any runs of whitespace down to single spaces.""" @@ -51,8 +88,15 @@ def elem_text(elem: ET.Element | None) -> str: return " ".join(" ".join(buf).split()) -def para_text(para: ET.Element | None) -> str: - """Extract inline text from a , rendering code/ref as plain text.""" +def para_text(para: ET.Element | None, links: RefLinks | None = None) -> str: + """Extract inline text from a , rendering code/ref as plain text. + + 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``.""" if para is None: return "" parts = [] @@ -63,7 +107,9 @@ def para_text(para: ET.Element | None) -> str: pass # skip structural children elif child.tag == "computeroutput": ref = child.find("ref[@kindref='member']") - if ref is not None: + if ref is not None and (link := ref_to_rst(ref, links)): + parts.append(link) + elif ref is not None: parts.append(f":c:func:`{(ref.text or '').rstrip('()').strip()}`") else: parts.append(f"``{elem_text(child)}``") @@ -72,7 +118,9 @@ def para_text(para: ET.Element | None) -> str: elif child.tag == "ref": kindref = child.get("kindref", "") t = (child.text or "").strip() - if kindref == "member" and t: + if link := ref_to_rst(child, links): + parts.append(link) + elif kindref == "member" and t: parts.append(f":c:func:`{t.rstrip('()').strip()}`") else: parts.append(elem_text(child)) @@ -80,10 +128,14 @@ def para_text(para: ET.Element | None) -> str: parts.append(elem_text(child)) if child.tail: parts.append(child.tail) - return " ".join(" ".join(parts).split()) + # 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()) -def list_to_rst_lines(listelem: ET.Element, marker: str) -> list[str]: +def list_to_rst_lines( + listelem: ET.Element, marker: str, links: RefLinks | None = None +) -> list[str]: """Convert or children to RST list lines. Each item's paragraphs go through `para_rst_lines`, so a list nested inside a @@ -99,7 +151,7 @@ def list_to_rst_lines(listelem: ET.Element, marker: str) -> list[str]: for item in listelem.findall("listitem"): item_lines: list[str] = [] for para in item.findall("para"): - block = para_rst_lines(para) + block = para_rst_lines(para, links) if block: if item_lines: item_lines.append("") @@ -112,7 +164,7 @@ def list_to_rst_lines(listelem: ET.Element, marker: str) -> list[str]: return lines -def para_rst_lines(para: ET.Element | None) -> list[str]: +def para_rst_lines(para: ET.Element | None, links: RefLinks | None = None) -> list[str]: """One as RST lines: its own prose first, then any lists it contains. `para_text` deliberately skips `orderedlist`/`itemizedlist` (and @@ -130,14 +182,14 @@ def para_rst_lines(para: ET.Element | None) -> list[str]: if para is None: return [] lines: list[str] = [] - text = para_text(para).strip() + text = para_text(para, links).strip() if text: lines.append(text) for child in para: if child.tag == "orderedlist": - sub = list_to_rst_lines(child, "#.") + sub = list_to_rst_lines(child, "#.", links) elif child.tag == "itemizedlist": - sub = list_to_rst_lines(child, "-") + sub = list_to_rst_lines(child, "-", links) else: continue if not sub: @@ -151,7 +203,7 @@ def para_rst_lines(para: ET.Element | None) -> list[str]: return lines -def section_to_rst(simplesect: ET.Element) -> list[str]: +def section_to_rst(simplesect: ET.Element, links: RefLinks | None = None) -> list[str]: """Render a (Arrange/Act/Assert) into RST lines. Emits a .. rubric:: for the title, then renders each child as an ordered list, unordered list, or plain prose paragraph.""" @@ -170,7 +222,7 @@ def section_to_rst(simplesect: ET.Element) -> list[str]: for child in simplesect: if child.tag != "para": continue - block = para_rst_lines(child) + block = para_rst_lines(child, links) if block: lines.extend(block) lines.append("") @@ -180,25 +232,21 @@ def section_to_rst(simplesect: ET.Element) -> list[str]: return lines -def see_to_rst(simplesect_see: ET.Element, api_html_dir: str) -> str: +def see_to_rst(simplesect_see: ET.Element, api_html_dir: str, testspec_html_dir: str = "") -> str: """Render a into a 'See also:' RST line. - Each becomes a hyperlink (safety-API refs), a :c:func: role - (member refs), or a plain code span, depending on its attributes.""" + Each becomes a hyperlink (see `ref_to_rst`), a :c:func: role + (unlinked member refs), or a plain code span, depending on its attributes.""" + links = RefLinks(api=api_html_dir, local=testspec_html_dir) refs: list[str] = [] for ref in simplesect_see.findall("para/ref"): name = (ref.text or "").strip() if not name: continue refid = ref.get("refid", "") - external = ref.get("external", "") kindref = ref.get("kindref", "") if refid and "_1" in refid: - idx = refid.rfind("_1") - compound = refid[:idx] - anchor = refid[idx + 2:] - if external: - url = f"{api_html_dir}/{compound}.html#{anchor}" - refs.append(f"`{name} <{url}>`__") + if link := ref_to_rst(ref, links): + refs.append(link) elif kindref == "member": func_name = name.rstrip("()").strip() refs.append(f":c:func:`{func_name}`") @@ -211,7 +259,9 @@ def see_to_rst(simplesect_see: ET.Element, api_html_dir: str) -> str: return "" -def extract_params(detaileddesc: ET.Element) -> list[tuple[str, str]]: +def extract_params( + detaileddesc: ET.Element, links: RefLinks | None = None +) -> list[tuple[str, str]]: """Walk all elements in the detailed description, collect each parameter's name(s) and prose description, and return them as (name, description) pairs. Multiple names per item are joined with ', '.""" @@ -224,7 +274,7 @@ def extract_params(detaileddesc: ET.Element) -> list[tuple[str, str]]: ] name = ", ".join(n for n in names if n) desc = " ".join( - para_text(p) + para_text(p, links) for p in item.findall(".//parameterdescription/para") ).strip() if name: @@ -233,7 +283,7 @@ def extract_params(detaileddesc: ET.Element) -> list[tuple[str, str]]: -def detail_rst_lines(dd: ET.Element | None) -> list[str]: +def detail_rst_lines(dd: ET.Element | None, links: RefLinks | None = None) -> list[str]: """A 's own prose as RST LINES — paragraphs and lists. Shared by member-level (`@details` on a ZTEST/function) and compound-level @@ -262,7 +312,7 @@ def detail_rst_lines(dd: ET.Element | None) -> list[str]: return [] lines: list[str] = [] for para in dd.findall("para"): - block = para_rst_lines(para) + block = para_rst_lines(para, links) if block: if lines: lines.append("") @@ -294,7 +344,8 @@ def parse_memberdef( anchor = member_id[len(prefix):] if member_id.startswith(prefix) else member_id doxygen_url = f"{testspec_html_dir}/{compound_id}.html#{anchor}" - brief = para_text(memberdef.find("briefdescription/para")) + links = RefLinks(api=api_html_dir, local=testspec_html_dir) + brief = para_text(memberdef.find("briefdescription/para"), links) dd = memberdef.find("detaileddescription") test_id = "" @@ -320,30 +371,52 @@ def parse_memberdef( # the recommended way to write a test case. `build_procedure_need_rst` # already searched with `.//` for its see-also, so this also removes an # inconsistency between the two builders. + # + # The value is read with itertext(), not .text: once any tag file in + # TAGFILES declares a requirement of that name (a `doxygen_tag:` needs + # tag, say), Doxygen autolinks an identifier-shaped UID such as + # DUTY_001 inside the xrefitem, `DUTY_001`, + # and .text is then empty. Hyphenated UIDs are never autolinked, which + # is why this stayed hidden. for xrefsect in dd.iter("xrefsect"): xid = xrefsect.get("id", "") - xdesc = (xrefsect.findtext("xrefdescription/para") or "").strip() + xpara = xrefsect.find("xrefdescription/para") + xdesc = "".join(xpara.itertext()).strip() if xpara is not None else "" if "testids" in xid: test_id = xdesc elif "reqrefs" in xid: for _p in xrefsect.findall("xrefdescription/para"): - _rid = (_p.text or "").strip() + _rid = "".join(_p.itertext()).strip() if _rid: req_ids.append(_rid) elif "test_active" in xid: status = "active" elif "test_obsolete" in xid: status = "obsolete" - detail_lines = detail_rst_lines(dd) + detail_lines = detail_rst_lines(dd, links) see_sect = dd.find(".//simplesect[@kind='see']") if see_sect is not None: - see_rst = see_to_rst(see_sect, api_html_dir) + see_rst = see_to_rst(see_sect, api_html_dir, testspec_html_dir) + + # Doxygen's native `\verifies` (1.16+): a child of the memberdef + # itself, not of the description, with the UID only in each requirement's + # refid. Read beside the `@reqref` xrefsects above; both are live while + # sources migrate. + # + # The refid is NOT proof the requirement exists: Doxygen synthesizes it from + # the UID string whether or not any `\requirement` defines it, so a typo is + # byte-identical here to a real link. Only Doxygen's warning ("Reference to + # unknown requirement") tells them apart. + for req in memberdef.findall("verifies/requirement"): + uid = req.get("refid", "").removeprefix("requirement_") + if uid and uid not in req_ids: + req_ids.append(uid) ibd = memberdef.find("inbodydescription") body_sections: list[list[str]] = [] if ibd is not None: for ss in ibd.findall("para/simplesect[@kind='par']"): - section_lines = section_to_rst(ss) + section_lines = section_to_rst(ss, links) if section_lines: body_sections.append(section_lines) diff --git a/sphinx/_extensions/rst_builders.py b/sphinx/_extensions/rst_builders.py index 77ba908..1325b4f 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -131,6 +131,7 @@ def build_procedure_need_rst( ): """Build a test_procedure needs item for one shared test procedure.""" from doxygen_parser import ( + RefLinks, detail_rst_lines, extract_params, para_text, @@ -140,6 +141,7 @@ 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")) title = (brief[:90] + "…") if len(brief) > 90 else brief if not title: @@ -163,13 +165,14 @@ def build_procedure_need_rst( params = [] see_rst_str = "" if dd is not None: - params = extract_params(dd) + links = RefLinks(api=api_html_dir, local=testspec_html_dir) + params = extract_params(dd, links) # Was a second, hand-rolled copy of the same paragraph walk, carrying the # same list-dropping defect. One helper now, so a fix lands in both. - detail_lines = detail_rst_lines(dd) + detail_lines = detail_rst_lines(dd, links) see_sect = dd.find(".//simplesect[@kind='see']") if see_sect is not None: - see_rst_str = see_to_rst(see_sect, api_html_dir) + see_rst_str = see_to_rst(see_sect, api_html_dir, testspec_html_dir) lines = [] lines.append(f".. {_need_name(need_names, 'procedure')}:: {title}")