diff --git a/cmake/registry.cmake b/cmake/registry.cmake index fa5db9a..9336eef 100644 --- a/cmake/registry.cmake +++ b/cmake/registry.cmake @@ -316,6 +316,21 @@ function(add_docs_from_registry) endforeach() endif() + # testmodule.coverage_source_docs: the testcoverage directive reads the + # source listings of these Doxygen documents (deploy/xml//), so the + # same stage-2 edge, once per document. + string(JSON _zdocs_coverage_n LENGTH "${_zdocs_entry}" "testmodule_coverage_source_docs") + if(_zdocs_coverage_n GREATER 0) + math(EXPR _zdocs_coverage_last "${_zdocs_coverage_n} - 1") + foreach(_zdocs_coverage_i RANGE ${_zdocs_coverage_last}) + string(JSON _zdocs_coverage_dox GET "${_zdocs_entry}" + "testmodule_coverage_source_docs" ${_zdocs_coverage_i}) + foreach(_zdocs_builder ${_zdocs_builders}) + add_dependencies(${_zdocs_id}-${_zdocs_builder} ${_zdocs_coverage_dox}) + endforeach() + endforeach() + endif() + string(JSON _zdocs_testmodule_spec GET "${_zdocs_entry}" "testmodule_spec") if(NOT _zdocs_testmodule_spec STREQUAL "") add_dependencies(${_zdocs_id}-index ${_zdocs_testmodule_spec}-index) diff --git a/doc/manual/howto/render-test-specifications.rst b/doc/manual/howto/render-test-specifications.rst index 3bff7d5..4754749 100644 --- a/doc/manual/howto/render-test-specifications.rst +++ b/doc/manual/howto/render-test-specifications.rst @@ -62,6 +62,10 @@ literally named ``ZTEST`` — expanding it also injects ``\ingroup suite`` from the macro's own argument, which is what attaches a test case to its suite without a hand-written (and driftable) ``@ingroup``. +Optional: ``SOURCE_BROWSER = YES`` makes Doxygen write a source listing of +each file. The Source line of each test case then links to the test's line in +that listing, not to the test's entry on the group page. + 4. Declare the needs vocabulary ------------------------------------ diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index e8b52b3..1363901 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -105,6 +105,13 @@ of ``testcase.yaml``, ``tests.yaml`` and ``sample.yaml`` that exists. Every ``ZTEST``/``ZTEST_SUITE``/... in the named group and its inner suite/procedure groups becomes one need each — nothing is written by hand per test case. +The Source line of a test case or a procedure (``main.c (line 290)``) links +to the Doxygen source listing of its file, at that line, if the test +specification's Doxyfile sets ``SOURCE_BROWSER = YES``. zdocs reads the +setting from the ``Doxyfile.xml`` that Doxygen writes beside the XML. Without +the listings, the Source line links to the member's documentation in the +group page. + A test case need's ``suite`` field is its inner suite group's name, so name that group after the ``ZTEST_SUITE``: ``testreport`` finds the test case for a twister result by the pair (suite, function). Two test modules that declare the @@ -382,9 +389,37 @@ requirement. Its body has: * The verifying tests, and the fields that the consumer does not declare (``verdict``, ``evidence``, ``coverage_run``). -The table cells use the roles ``zd-adq-ok``, ``zd-adq-miss``, ``zd-adq-none`` -and ``zd-adq-loc``. Each gives the text a class of the same name. The zdocs -need card (:ref:`need-layouts`) colors them. +The table cells use the roles ``zd-adq-ok``, ``zd-adq-miss`` and +``zd-adq-none``. Each gives the text a class of the same name. The form and +place of a body is a paragraph of class ``zd-adq-loc``. The zdocs need card +(:ref:`need-layouts`) colors them. + +The place of a body (``kernel/thread.c:344-358``) links to the first line of +the body in a Doxygen source listing. The registry key +``testmodule.coverage_source_docs`` names the Doxygen documents to look in, in +order; the first that has a listing of the file wins: + +.. code-block:: yaml + + test-report: + kind: sphinx + testmodule: + spec: test-specification + coverage_source_docs: [dox-detailed-design, dox-api] + +The registry gives this list to the build as ``testcoverage_source_docs``, and +each builder of the document waits for these Doxygen documents. The links +read the XML of each document (``deploy/xml//``), so the top-level +``doxygen_xml: true`` of the registry is necessary. Without it, there is no +XML and no link. See :doc:`registry-schema`. + +The body path is relative to ``testmodule_root``; the Doxygen path can have +more or fewer directories in front (``zephyr/kernel/thread.c``), if only one +file of the document matches. A Doxygen document lists every code file with +``SOURCE_BROWSER = YES``, and only the headers with ``VERBATIM_HEADERS = YES``. +The default is ``[]``: no links. A ``conf.py`` can also set +``testcoverage_source_docs``, but then the build does not wait for the +Doxygen documents. The id of a need is ``ADQ-/``. ``testcoverage_id_prefix`` sets the prefix. The type, the link and the fields are roles, as for the other @@ -444,8 +479,11 @@ Each need is titled with the symbol name and has the id ``-``, where ```` is the need type's name in upper case (``IMPL-k_queue_init``). Its body gives the kind, the brief, a link to the symbol's Doxygen page and the file it is declared in; no custom field is -needed for them. The need type and link are engine roles, named by the -consumer like the test directives' (ADR-0009): +needed for them. "Declared in" links to the declaration's line in Doxygen's +source listing, if the run wrote one: with ``SOURCE_BROWSER = YES`` for every +file, with ``VERBATIM_HEADERS = YES`` (the Doxygen default) for the headers. +The need type and link are engine roles, named by the consumer like the test +directives' (ADR-0009): .. code-block:: python diff --git a/doc/manual/reference/registry-schema.rst b/doc/manual/reference/registry-schema.rst index 13fe894..fa34ec6 100644 --- a/doc/manual/reference/registry-schema.rst +++ b/doc/manual/reference/registry-schema.rst @@ -324,6 +324,16 @@ Each entry in ``documents:``, keyed by its id: It shows as a literal in the "See also" line, without a link. The line has the targets of all ``@see`` lines of the test, in order. + ``coverage_source_docs`` + A list of ``kind: doxygen`` document ids. The ``testcoverage`` + directive links the place of each body to the source listing of the + first of these documents that has the file (see + :doc:`directives-and-roles`). Every stage-2 builder of this document + waits for each of them, as for ``doxygen_source``. The links read the + XML (``deploy/xml//``), so the top-level ``doxygen_xml: true`` is + necessary. A value that is not a list, or an id that is not an + existing ``kind: doxygen`` document, is a configure-time error. + ``spec`` Id of the sphinx document whose exported needs a ``testreport`` document correlates against. Must exist, and must carry ``needs: {source: json}`` diff --git a/scripts/docrefs.py b/scripts/docrefs.py index c3b598e..dd72900 100644 --- a/scripts/docrefs.py +++ b/scripts/docrefs.py @@ -144,7 +144,7 @@ def _registry(registry): #: typo'd ``doxygen_src:``) must be a configure-time error, not silently #: ignored: silently ignoring it would degrade to "no XML" with no #: diagnostic at all. -_TESTMODULE_KEYS = ("doxygen_source", "api_reference", "spec") +_TESTMODULE_KEYS = ("doxygen_source", "api_reference", "spec", "coverage_source_docs") #: Allowed keys inside a document's ``symbol_needs:`` block. ``doxygen_source`` @@ -358,6 +358,33 @@ def _validate(data): f"'kind: doxygen' document" ) + # The Doxygen documents whose source listings the testcoverage + # directive links the bodies to, in order. A list, so each id is + # checked as doxygen_source is; the build waits for each of them. + coverage_docs = testmodule.get("coverage_source_docs") + if coverage_docs is not None: + if not isinstance(coverage_docs, list): + raise ValueError( + f"docrefs: document '{doc_id}' has " + f"testmodule.coverage_source_docs: '{coverage_docs}' " + f"— use a list of 'kind: doxygen' document ids" + ) + for ref_id in coverage_docs: + ref_meta = data.get("documents", {}).get(ref_id) + if ref_meta is None: + raise ValueError( + f"docrefs: document '{doc_id}' has " + f"testmodule.coverage_source_docs: '{ref_id}', " + f"which does not exist in the registry" + ) + ref_kind = ref_meta.get("kind", "sphinx") + if ref_kind != "doxygen": + raise ValueError( + f"docrefs: document '{doc_id}' has " + f"testmodule.coverage_source_docs: '{ref_id}', " + f"which is kind '{ref_kind}', not 'doxygen'" + ) + spec_id = testmodule.get("spec") if spec_id is not None: spec_meta = data.get("documents", {}).get(spec_id) @@ -643,6 +670,7 @@ def __init__( deploy_dirs=None, testmodule=None, symbol_needs=None, + doxygen_docs=None, ): self.reference_groups = reference_groups self.intersphinx_mapping = intersphinx_mapping @@ -666,6 +694,11 @@ def __init__( #: loaded). A dict with ``xml_dir`` (the Doxygen document's XML) and #: ``doxygen_url`` (its HTML, relative to this document's root). self.symbol_needs = symbol_needs + #: Every ``kind: doxygen`` document: ``{id: {"xml_dir", "html_url"}}``, + #: its XML and its HTML relative to this document's root. For an + #: extension that a ``conf.py`` points at a Doxygen document by id + #: (``testcoverage_source_docs``). + self.doxygen_docs = doxygen_docs or {} def _resolve_external_url(url, external_base_url): @@ -963,6 +996,7 @@ def _nav_href(doc_id, meta): "api_url": rel_urls.get(api_reference, "") if api_reference else "", "needs_json": needs_json, "tag_urls": tag_urls(documents, deploy, rel_urls), + "coverage_source_docs": list(testmodule_block.get("coverage_source_docs") or []), } symbol_needs = None @@ -974,6 +1008,12 @@ def _nav_href(doc_id, meta): "doxygen_url": rel_urls.get(source, ""), } + doxygen_docs = { + doc_id: {"xml_dir": str(deploy / "xml" / doc_id), "html_url": rel_urls[doc_id]} + for doc_id, meta in documents.items() + if meta.get("kind") == "doxygen" and doc_id in rel_urls + } + return Refs( reference_groups, intersphinx_mapping, @@ -985,6 +1025,7 @@ def _nav_href(doc_id, meta): deploy_dirs=deploy_dirs, testmodule=testmodule, symbol_needs=symbol_needs, + doxygen_docs=doxygen_docs, ) @@ -1271,6 +1312,10 @@ def manifest(registry=None): never its own spec), so this cannot cycle; it is deliberately NOT generalised to every needs-importer/publisher pair, which CAN cycle and is its own, deferred step. + * ``testmodule_coverage_source_docs`` — this document's own + ``testmodule.coverage_source_docs`` (``[]`` without it). Every + stage-2 builder of the document waits for each of these Doxygen + documents, as for ``testmodule_doxygen_source``. * ``symbol_needs_doxygen_source`` — this document's own ``symbol_needs:`` block's ``doxygen_source`` (``None`` without the block). Wired like ``testmodule_doxygen_source``: every stage-2 @@ -1294,6 +1339,9 @@ def manifest(registry=None): "remote_tagfile": meta.get("remote-tagfile"), "testmodule_doxygen_source": testmodule.get("doxygen_source"), "testmodule_spec": testmodule.get("spec"), + "testmodule_coverage_source_docs": list( + testmodule.get("coverage_source_docs") or [] + ), "symbol_needs_doxygen_source": (meta.get("symbol_needs") or {}).get( "doxygen_source" ), diff --git a/sphinx/_extensions/_tests/test_adequacy.py b/sphinx/_extensions/_tests/test_adequacy.py index fbcad7d..334c735 100644 --- a/sphinx/_extensions/_tests/test_adequacy.py +++ b/sphinx/_extensions/_tests/test_adequacy.py @@ -37,6 +37,7 @@ import json import subprocess +import sys from pathlib import Path import adequacy as A @@ -44,6 +45,9 @@ import test_coverage as tc from rst_builders import _DEFAULT_NEED_NAMES +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts")) +import docrefs # noqa: E402 + OBJ_C = """\ #include @@ -494,7 +498,10 @@ def test_adequacy_rst_gives_one_row_per_body(tree): # The function whose body was read, its form and place, the own lines of # the lines any test ran, and the other test cases. assert " * - ``z_impl_k_obj_init``" in rst - assert f":zd-adq-loc:`implementation, kernel/obj.c:{IMPL[0]}-{IMPL[1]}`" in rst + assert ( + " .. rst-class:: zd-adq-loc\n\n" + f" implementation, kernel/obj.c:{IMPL[0]}-{IMPL[1]}\n" + ) in rst assert ":zd-adq-none:`no test ran it`" in rst assert " * - ``z_vrfy_k_obj_init``" in rst assert ":zd-adq-ok:`3 of 3 lines`" in rst @@ -502,6 +509,23 @@ def test_adequacy_rst_gives_one_row_per_body(tree): assert "Other tests that run" not in rst +def test_adequacy_rst_links_the_place_of_a_body_to_its_listing(tree): + run, results, _, _ = _assess(tree) + calls = [] + + def body_url(path, line): + calls.append((path, line)) + return f"dd/{path}#l{line}" if path == "kernel/obj.c" else "" + + rst = "\n".join(tc.build_adequacy_rst("R-VRFY", results["R-VRFY"], run, body_url=body_url)) + a, b = IMPL + assert f"implementation, `kernel/obj.c:{a}-{b}
`__" in rst + assert ("kernel/obj.c", a) in calls + rst = "\n".join(tc.build_adequacy_rst("R-INLINE", results["R-INLINE"], run, body_url=body_url)) + a, b = INLINE + assert f"static inline, include/zephyr/sys/k_inline.h:{a}-{b}\n" in rst # no listing + + def test_adequacy_rst_names_the_other_tests_of_a_missed_body(tree): run, results, _, _ = _assess(tree) rst = "\n".join(tc.build_adequacy_rst("R-BROKEN", results["R-BROKEN"], run)) @@ -634,6 +658,106 @@ def test_directive_reads_the_consumers_file_set(tree, make_app, tmp_path): assert "include/zephyr/sys/**/*.h" in html and "kernel/*.c" not in html +def _doxygen_run(path, files): + """A Doxygen XML dir with SOURCE_BROWSER = YES and ``files`` ({refid: path}).""" + path.mkdir() + (path / "Doxyfile.xml").write_text( + "" + ) + (path / "index.xml").write_text("" + "".join( + f"{Path(f).name}" + for r, f in files.items() + ) + "") + for r, f in files.items(): + (path / f"{r}.xml").write_text( + f"" + "" + ) + return path + + +def test_directive_links_each_body_to_its_source_listing(tree, make_app, tmp_path): + root, _, run_dir, needs_json = tree + xml = _doxygen_run(tmp_path / "dd-xml", {"zephyr_2kernel_2plain_8c": "zephyr/kernel/plain.c"}) + src = tmp_path / "doc" + src.mkdir() + (src / "conf.py").write_text(_CONF.format( + ext=str(Path(A.__file__).parent), needs=str(needs_json), run=str(run_dir), + root=str(root), + ) + ( + f"zdocs_doxygen_docs = {{'dd': {{'xml_dir': {str(xml)!r}, 'html_url': '../dd'}}}}\n" + "testcoverage_source_docs = ['nope', 'dd']\n" + )) + (src / "index.rst").write_text("Adequacy\n########\n\n.. testcoverage::\n") + app = make_app("html", srcdir=src) + app.build() + html = (Path(app.outdir) / "index.html").read_text() + # The body path is from the tree root, Doxygen's from one level up. + assert f'href="../dd/zephyr_2kernel_2plain_8c_source.html#l{PLAIN[0]:05d}"' in html + log = app._warning.getvalue() + assert "testcoverage_source_docs names 'nope'" in log + inputs = set(app.env.zdocs_report_inputs["index"]) + for name in ("Doxyfile.xml", "index.xml", "zephyr_2kernel_2plain_8c.xml"): + assert str(xml / name) in inputs + + +# --------------------------------------------------------------------------- +# Registry: testmodule.coverage_source_docs +# --------------------------------------------------------------------------- + +REGISTRY = """\ +groups: + - id: reference + title: Reference +documents: + report: + kind: sphinx + group: reference + builders: [html] + testmodule: + coverage_source_docs: [dox-dd, dox-api] + dox-dd: + kind: doxygen + group: reference + dox-api: + kind: doxygen + group: reference +""" + + +def _registry(tmp_path, text=REGISTRY): + path = tmp_path / "documents.yaml" + path.write_text(text) + return path + + +def test_registry_gives_the_coverage_source_docs(tmp_path, monkeypatch): + monkeypatch.setenv("OUTPUT_DIR", str(tmp_path / "deploy" / "html" / "report")) + refs = docrefs.load("report", _registry(tmp_path)) + assert refs.testmodule["coverage_source_docs"] == ["dox-dd", "dox-api"] + assert set(refs.doxygen_docs) == {"dox-dd", "dox-api"} + + +def test_manifest_names_the_coverage_source_docs(tmp_path): + manifest = docrefs.manifest(_registry(tmp_path)) + entries = {e["id"]: e["testmodule_coverage_source_docs"] for e in manifest} + assert entries == {"report": ["dox-dd", "dox-api"], "dox-dd": [], "dox-api": []} + + +@pytest.mark.parametrize( + ("after", "message"), + [ + ("coverage_source_docs: dox-dd", "use a list"), + ("coverage_source_docs: [nowhere]", "does not exist"), + ("coverage_source_docs: [report]", "not 'doxygen'"), + ], +) +def test_invalid_coverage_source_docs_fail_validation(tmp_path, after, message): + text = REGISTRY.replace("coverage_source_docs: [dox-dd, dox-api]", after) + with pytest.raises(ValueError, match=message): + docrefs.manifest(_registry(tmp_path, text)) + + def test_directive_without_a_run_says_so(make_app, tmp_path): src = tmp_path / "doc" src.mkdir() diff --git a/sphinx/_extensions/_tests/test_symbol_needs.py b/sphinx/_extensions/_tests/test_symbol_needs.py index 4dc42e9..3020743 100644 --- a/sphinx/_extensions/_tests/test_symbol_needs.py +++ b/sphinx/_extensions/_tests/test_symbol_needs.py @@ -99,6 +99,18 @@ def test_symbol_need_body_has_kind_link_and_file(): assert "**Declared in:** ``queue.h (line 39)``" in rst +def test_declared_in_links_the_header_listing(): + # SOURCE_BROWSER = NO, VERBATIM_HEADERS = YES: Doxygen lists the headers. + pages = dp.SourcePages(XML) + info = dp.parse_symbol(_member("K_QUEUE_MAX"), "api", pages) + assert info["source_url"] == "api/queue_8h_source.html#l00039" + rst = rb.build_symbol_need_rst(info) + assert "**Declared in:** `queue.h (line 39) `__" in rst + assert any( + "`__" in line for line in sn.symbol_needs_rst(XML, "api") + ) + + # --------------------------------------------------------------------------- # symbol_needs_rst # --------------------------------------------------------------------------- @@ -133,6 +145,8 @@ def test_every_read_file_is_reported_as_input(): "queue_8h.xml", "req_8dox.xml", "group__queue__apis.xml", + "Doxyfile.xml", + "index.xml", } diff --git a/sphinx/_extensions/_tests/test_test_module.py b/sphinx/_extensions/_tests/test_test_module.py index 3f73c52..2519c7c 100644 --- a/sphinx/_extensions/_tests/test_test_module.py +++ b/sphinx/_extensions/_tests/test_test_module.py @@ -14,6 +14,7 @@ import test_module as tm from docutils import nodes from docutils.utils import Reporter +from doxygen_parser import SourcePages from twister_reader import SpecLookup FIXTURES = Path(__file__).resolve().parents[2] / "_fixtures" @@ -337,6 +338,28 @@ def test_build_proc_group_rst_heading(): assert lines[1] == "-" * len("Queue Test Procedures") +def test_build_suite_rst_links_the_source_listing(): + lines = tm._build_suite_rst( + "group__queue__api", DOXYGEN, "../testspec/html", "../api/html", "tests/kernel/queue", + source_pages=SourcePages(DOXYGEN), + ) + text = "\n".join(lines) + assert "`test_queue.c (line 42) <../testspec/html/test__queue_8c_source.html#l00042>`__" in text + # Without the listings: the member's page, as before. + lines = tm._build_suite_rst( + "group__queue__api", DOXYGEN, "../testspec/html", "../api/html", "tests/kernel/queue" + ) + assert "<../testspec/html/group__queue__api.html#a001>`__" in "\n".join(lines) + + +def test_build_proc_group_rst_links_the_source_listing(): + lines = tm._build_proc_group_rst( + "group__queue__procedures", DOXYGEN, "../testspec/html", "../api/html", + source_pages=SourcePages(DOXYGEN), + ) + assert "<../testspec/html/test__helpers_8c_source.html#l00010>`__" in "\n".join(lines) + + def test_build_proc_group_rst_contains_procedure_ids(): lines = tm._build_proc_group_rst( "group__queue__procedures", DOXYGEN, "../testspec/html", "../api/html" @@ -399,6 +422,17 @@ def test_testmodule_directive_generates_test_case_ids(app): assert "TSPEC-QUEUE-API-002" in html +@pytest.mark.sphinx("html", srcdir=str(_ROOTS / "test-testmodule")) +def test_testmodule_directive_links_the_source_listing(app): + app.build() + html = (Path(app.outdir) / "index.html").read_text() + assert 'href="testspec/test__queue_8c_source.html#l00042"' in html + xml_dir = Path(app.config.testmodule_xml_dir) + inputs = set(app.env.zdocs_report_inputs["index"]) + for name in ("Doxyfile.xml", "index.xml", "test__queue_8c.xml"): + assert str(xml_dir / name) in inputs + + @pytest.mark.sphinx("html", srcdir=str(_ROOTS / "test-testmodule")) def test_testmodule_directive_suite_heading(app): app.build() diff --git a/sphinx/_extensions/symbol_needs.py b/sphinx/_extensions/symbol_needs.py index d442dbd..14f6575 100644 --- a/sphinx/_extensions/symbol_needs.py +++ b/sphinx/_extensions/symbol_needs.py @@ -21,7 +21,7 @@ from docutils import nodes from docutils.parsers.rst import Directive from docutils.statemachine import ViewList -from doxygen_parser import load_group_index, parse_symbol +from doxygen_parser import SourcePages, load_group_index, parse_symbol from input_tracking import _note_input from needs_fields import depends_field from rst_builders import build_symbol_need_rst @@ -65,9 +65,12 @@ def symbol_needs_rst( Doxygen defines it, however many compounds list it. ``note_input`` is called with every XML file read. ``depends_field(conditions, subject)`` decides whether a need gets the ``depends_on`` field - (`needs_fields.depends_field`); without it, none does. + (`needs_fields.depends_field`); without it, none does. "Declared in" + links to the source listing of the run, if it wrote one + (`doxygen_parser.SourcePages`). """ xml_dir = Path(xml_dir) + source_pages = SourcePages(xml_dir) lines, seen = [], set() for refid, name in _compounds(xml_dir, group): xml = xml_dir / f"{refid}.xml" @@ -82,7 +85,7 @@ def symbol_needs_rst( if md.find("satisfies") is None or md.get("id") in seen: continue seen.add(md.get("id")) - info = parse_symbol(md, html_dir) + info = parse_symbol(md, html_dir, source_pages) with_depends = bool(depends_field) and depends_field( info["depends_on"], f"symbolneeds: {info['name']}" ) @@ -94,6 +97,9 @@ def symbol_needs_rst( title = cdef.findtext("title") or name lines += [title, "-" * len(title), ""] lines += blocks + if note_input: + for path in sorted(source_pages.inputs): + note_input(path) return lines diff --git a/sphinx/_extensions/test_coverage.py b/sphinx/_extensions/test_coverage.py index 802256b..ccf1812 100644 --- a/sphinx/_extensions/test_coverage.py +++ b/sphinx/_extensions/test_coverage.py @@ -27,6 +27,7 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import ViewList +from doxygen_parser import SourcePages from input_tracking import _note_input from needs_fields import field_type from rst_builders import _need_name @@ -58,8 +59,9 @@ "inline": "static inline"} #: The roles of the body table cells: each gives an inline node with the -#: role name as its class, for ``zdocs-needs.css``. -CELL_ROLES = ("zd-adq-ok", "zd-adq-miss", "zd-adq-none", "zd-adq-loc") +#: role name as its class, for ``zdocs-needs.css``. The place of a body is a +#: paragraph of class ``zd-adq-loc``. +CELL_ROLES = ("zd-adq-ok", "zd-adq-miss", "zd-adq-none") #: The engine fields that the body shows when the consumer does not declare them. _BODY_FALLBACK = (("verdict", "Verdict"), ("evidence", "Evidence"), @@ -178,7 +180,7 @@ def _other_count(d, run): def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", layout="", - title=None): + title=None, body_url=None): """RST lines for the adequacy need of ``req`` (``res`` from `adequacy.assess`). ``layout``: the sphinx-needs layout of the need, if not empty. ``title``: @@ -190,6 +192,9 @@ def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", test of the run ran, and the number of other test cases that ran it. For a body that the own tests never run, the body names the other tests that do. A field that the consumer does not declare is in the body too. + + ``body_url(file, line)``: the source listing of a body at its first line, + or ``""``; the place of the body links to it. """ values = { "verdict": res["verdict"], @@ -224,11 +229,15 @@ def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", name = _BODY_NAME[d["variant"]].format(d["sym"]) others = _other_count(d, run) others = f"{others} test case{'s' if others != 1 else ''}" if others else "none" + place = f"{d['file']}:{d['a']}-{d['b']}" + url = body_url(d["file"], d["a"]) if body_url else "" + place = f"`{place} <{url}>`__" if url else place lines += [ f" * - ``{name}``", "", - f" :zd-adq-loc:`{_BODY_FORM[d['variant']]}, " - f"{d['file']}:{d['a']}-{d['b']}`", + " .. rst-class:: zd-adq-loc", + "", + f" {_BODY_FORM[d['variant']]}, {place}", f" - {_own_cell(d)}", f" - {others}", ] @@ -257,12 +266,13 @@ def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", def build_coverage_rst( results, run, source, impl_loc, need_names=None, fields=(), prefix="ADQ", layout="", - impl_files=IMPL_PATTERNS, titles=None, + impl_files=IMPL_PATTERNS, titles=None, body_url=None, ): """RST lines for the whole directive: run summary, distribution, needs by verdict. ``impl_files``: the body files that the run searched, for the summary. ``titles``: the title of each requirement, for the title of its need. + ``body_url``: see `build_adequacy_rst`. """ counts = Counter(r["verdict"] for r in results.values()) symbols = sorted({s for r in results.values() for s in r["symbols"]}) @@ -306,7 +316,7 @@ def build_coverage_rst( for req in reqs: lines += build_adequacy_rst( req, results[req], run, need_names, fields, prefix, layout, - (titles or {}).get(req), + (titles or {}).get(req), body_url, ) return lines @@ -336,6 +346,36 @@ class TestCoverageDirective(Directive): def _paragraph(self, text): return [nodes.paragraph(text=text)] + def _body_url(self, env): + """``(file, line) -> URL`` of the first source listing that has the file, + of the Doxygen documents in ``testcoverage_source_docs``, in that order, + and the `SourcePages` it reads (their inputs are noted after the use).""" + app, config = env.app, env.app.config + docs = getattr(config, "zdocs_doxygen_docs", {}) or {} + page_prefix = "../" * (len(Path(env.docname).parts) - 1) + cache = app.__dict__.setdefault("_testcoverage_source_pages", {}) + listings = [] + for doc_id in getattr(config, "testcoverage_source_docs", []) or []: + doc = docs.get(doc_id) + if doc is None: + logger.warning( + f"testcoverage: testcoverage_source_docs names '{doc_id}', " + f"which is no kind: doxygen document of the registry" + ) + continue + if doc_id not in cache: + cache[doc_id] = SourcePages(doc["xml_dir"]) + listings.append((cache[doc_id], page_prefix + doc["html_url"])) + + def body_url(path, line): + for pages, html_dir in listings: + url = pages.url(html_dir, path, line, tail=True) + if url: + return url + return "" + + return (body_url if listings else None), [pages for pages, _ in listings] + def run(self): env = self.state.document.settings.env app = env.app @@ -392,13 +432,20 @@ def run(self): role for role in ADEQUACY_FIELD_ROLES if field_type(env, _need_name(need_names, role)) is not None } + body_url, source_pages = self._body_url(env) lines = build_coverage_rst( results, run, source, impl_loc, need_names, fields, getattr(config, "testcoverage_id_prefix", "ADQ"), self.options.get("layout", "").strip(), impl_files, need_titles(json_paths), + body_url, ) + # The listings are shared by all documents: this notes the files that + # the lookups of all documents so far read, a superset of this one's. + for pages in source_pages: + for path in sorted(pages.inputs): + _note_input(env, path) dump_dir = getattr(config, "dump_generated_rst", "") if dump_dir: out = Path(dump_dir) @@ -428,6 +475,10 @@ def setup(app): app.add_config_value("testcoverage_id_prefix", "ADQ", "env") # Glob patterns of the files that hold the bodies, relative to testmodule_root. app.add_config_value("testcoverage_impl_files", list(IMPL_PATTERNS), "env") + # The Doxygen documents (registry ids) whose source listings the place of + # a body links to: the first that has the file. + app.add_config_value("testcoverage_source_docs", [], "env") + app.add_config_value("zdocs_doxygen_docs", {}, "env") app.add_config_value("testcoverage_need_types", {"adequacy": "adequacy"}, "env") app.add_config_value("testcoverage_need_links", {"assesses": "assesses"}, "env") # Field roles -> names. A field is set only where the consumer declares it. diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index 06e18ee..59f0414 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -10,7 +10,7 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import ViewList -from doxygen_parser import detail_rst_lines, load_group_index, parse_memberdef +from doxygen_parser import SourcePages, detail_rst_lines, load_group_index, parse_memberdef from input_tracking import ( # noqa: F401 (the other hooks are re-exported for tests) _merge_inputs, _note_input, @@ -175,7 +175,7 @@ def suite_name_from_group(group_name, qualifier=""): def _build_suite_rst( suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=None, - depends_field=None, suite_qualifier="", tag_dirs=None, + depends_field=None, suite_qualifier="", tag_dirs=None, source_pages=None, ): """Build RST lines for one test suite group (section heading + test_case needs). @@ -189,6 +189,8 @@ def _build_suite_rst( ``tag_dirs``: where a tag-file reference links, by tag file (`doxygen_parser.RefLinks.tags`); a reference none names goes to ``api_html_dir``. + + ``source_pages``: the source listings of the run (`doxygen_parser.SourcePages`). """ suite_xml = xml_dir / f"{suite_refid}.xml" if not suite_xml.exists(): @@ -207,7 +209,7 @@ def _build_suite_rst( lines.append("") for memberdef in suite_cdef.findall(".//memberdef[@kind='function']"): info = parse_memberdef( - memberdef, compound_id, testspec_html_dir, api_html_dir, tag_dirs + memberdef, compound_id, testspec_html_dir, api_html_dir, tag_dirs, source_pages ) if not info["name"]: continue @@ -225,7 +227,8 @@ def _build_suite_rst( def _build_proc_group_rst( - proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=None, tag_dirs=None + proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=None, tag_dirs=None, + source_pages=None, ): """Build RST lines for one procedure group (section heading + test_procedure needs).""" proc_xml = xml_dir / f"{proc_refid}.xml" @@ -245,7 +248,7 @@ def _build_proc_group_rst( lines.extend( build_procedure_need_rst( md, proc_compound_id, proc_group_name, testspec_html_dir, api_html_dir, - need_names=need_names, tag_dirs=tag_dirs, + need_names=need_names, tag_dirs=tag_dirs, source_pages=source_pages, ).splitlines() ) lines.append("") @@ -630,6 +633,12 @@ def run(self): _note_input(env, xml_dir / f"{inner.get('refid')}.xml") suite_refids, proc_refids = _classify_inner_groups(module_cdef, xml_dir) + # The source listings, for the Source links: once per build, as the + # group index. Its inputs are noted after the lookups below. + source_pages = getattr(app, "_testmodule_source_pages", None) + if source_pages is None: + source_pages = app._testmodule_source_pages = SourcePages(xml_dir) + need_names = _need_names_from_config(app) # Each name twister accepts is an input, so a scenario file that is # added or renamed later also re-reads the document. @@ -647,13 +656,17 @@ def run(self): env, conditions, subject ), suite_qualifier=getattr(app.config, "testmodule_suite_qualifier", ""), - tag_dirs=tag_dirs, + tag_dirs=tag_dirs, source_pages=source_pages, ) for proc_refid in proc_refids: all_rst += _build_proc_group_rst( proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=need_names, - tag_dirs=tag_dirs, + tag_dirs=tag_dirs, source_pages=source_pages, ) + # The listings are shared by all documents: this notes the files that + # the lookups of all documents so far read, a superset of this one's. + for path in sorted(source_pages.inputs): + _note_input(env, path) _maybe_dump_rst(app, env.docname, "testmodule", group_name, "\n".join(all_rst)) return _render_rst(all_rst, self.state, self.content_offset, match_titles=True) diff --git a/sphinx/_fixtures/doxygen-symbols/Doxyfile.xml b/sphinx/_fixtures/doxygen-symbols/Doxyfile.xml new file mode 100644 index 0000000..5b58fd7 --- /dev/null +++ b/sphinx/_fixtures/doxygen-symbols/Doxyfile.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sphinx/_fixtures/doxygen/Doxyfile.xml b/sphinx/_fixtures/doxygen/Doxyfile.xml new file mode 100644 index 0000000..36cabbe --- /dev/null +++ b/sphinx/_fixtures/doxygen/Doxyfile.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sphinx/_fixtures/doxygen/index.xml b/sphinx/_fixtures/doxygen/index.xml index 8548f29..98beedb 100644 --- a/sphinx/_fixtures/doxygen/index.xml +++ b/sphinx/_fixtures/doxygen/index.xml @@ -9,4 +9,10 @@ queue_procedures + + test_queue.c + + + test_helpers.c + diff --git a/sphinx/_fixtures/doxygen/test__helpers_8c.xml b/sphinx/_fixtures/doxygen/test__helpers_8c.xml new file mode 100644 index 0000000..2318a6e --- /dev/null +++ b/sphinx/_fixtures/doxygen/test__helpers_8c.xml @@ -0,0 +1,7 @@ + + + + test_helpers.c + + + diff --git a/sphinx/_fixtures/doxygen/test__queue_8c.xml b/sphinx/_fixtures/doxygen/test__queue_8c.xml new file mode 100644 index 0000000..d0aea41 --- /dev/null +++ b/sphinx/_fixtures/doxygen/test__queue_8c.xml @@ -0,0 +1,7 @@ + + + + test_queue.c + + + diff --git a/sphinx/_scripts/_tests/test_doxygen_parser.py b/sphinx/_scripts/_tests/test_doxygen_parser.py index 228ed50..d6110b9 100644 --- a/sphinx/_scripts/_tests/test_doxygen_parser.py +++ b/sphinx/_scripts/_tests/test_doxygen_parser.py @@ -614,3 +614,105 @@ def test_parse_memberdef_reads_every_see_section(): info = dp.parse_memberdef(md, "group__s", SPEC, API) assert "k_fifo_get()" in info["see_rst"] assert info["see_rst"].endswith(", ``irq_offload()``") + + +# --------------------------------------------------------------------------- +# SourcePages: the source listing of a test's file, for its Source link +# --------------------------------------------------------------------------- + +_DOXYFILE = ( + "" +) + + +def _run(tmp_path, source_browser="YES", files=None): + """A Doxygen XML dir with ``files`` ({refid: path}) as file compounds.""" + files = files or {} + if source_browser is not None: + (tmp_path / "Doxyfile.xml").write_text(_DOXYFILE.format(source_browser)) + compounds = "".join( + f"{Path(path).name}" + for refid, path in files.items() + ) + (tmp_path / "index.xml").write_text(f"{compounds}") + for refid, path in files.items(): + (tmp_path / f"{refid}.xml").write_text( + f"" + # A member's location first: only the compound's own location counts. + f"" + f"" + ) + return tmp_path + + +def test_source_pages_take_the_file_compound_of_the_path(tmp_path): + # Two files with one name: the path decides, not Doxygen's escaping. + pages = dp.SourcePages(_run(tmp_path, files={ + "a_2src_2main_8c": "tests/a/src/main.c", + "b_2src_2main_8c": "tests/b/src/main.c", + })) + assert pages.url("../spec", "tests/b/src/main.c", "290") == ( + "../spec/b_2src_2main_8c_source.html#l00290" + ) + assert pages.url("../spec", "tests/a/src/main.c", 7) == ( + "../spec/a_2src_2main_8c_source.html#l00007" + ) + assert pages.url("../spec", "tests/c/src/main.c", 7) == "" + assert pages.url("../spec", "tests/a/src/main.c", "") == ( + "../spec/a_2src_2main_8c_source.html" + ) + + +def test_source_pages_report_the_files_they_read(tmp_path): + xml = _run(tmp_path, files={"a_8c": "src/a.c", "b_8c": "src/b.c"}) + pages = dp.SourcePages(xml) + assert pages.inputs == {xml / "Doxyfile.xml", xml / "index.xml"} + pages.url("html", "src/a.c", 1) + assert pages.inputs == {xml / "Doxyfile.xml", xml / "index.xml", xml / "a_8c.xml"} + + +def test_source_pages_of_headers_only(tmp_path): + # VERBATIM_HEADERS (the default) without SOURCE_BROWSER: headers only. + pages = dp.SourcePages(_run(tmp_path, "NO", {"k_8h": "inc/k.h", "k_8c": "src/k.c"})) + assert pages.url("d", "inc/k.h", 3) == "d/k_8h_source.html#l00003" + assert pages.url("d", "src/k.c", 3) == "" + + +def test_source_pages_by_the_tail_of_the_path(tmp_path): + pages = dp.SourcePages(_run(tmp_path, files={ + "a_8c": "zephyr/kernel/thread.c", + "b_8h": "zephyr/kernel.h", + "c_8h": "zephyr/sys/util.h", + "d_8h": "zephyr/arch/util.h", + })) + # The other root: longer or shorter than Doxygen's path. + assert pages.refid("kernel/thread.c") == "" + assert pages.refid("kernel/thread.c", tail=True) == "a_8c" + assert pages.refid("include/zephyr/kernel.h", tail=True) == "b_8h" + assert pages.refid("include/zephyr/sys/util.h", tail=True) == "c_8h" + # Two files end the same way: no guess. + assert pages.refid("util.h", tail=True) == "" + # A name is not a tail of a longer name. + assert pages.refid("ernel/thread.c", tail=True) == "" + + +@pytest.mark.parametrize("source_browser", ["NO", None]) +def test_no_source_pages_without_source_browser(tmp_path, source_browser): + # NO, or a Doxygen that writes no Doxyfile.xml: no listing of a C file. + pages = dp.SourcePages(_run(tmp_path, source_browser, {"m_8c": "m.c"})) + assert pages.url("../spec", "m.c", 1) == "" + + +def test_parse_memberdef_links_the_source_listing(tmp_path): + pages = dp.SourcePages(_run(tmp_path, files={"t_8c": "tests/t.c"})) + md = ET.fromstring( + "test_x" + "" + "" + ) + info = dp.parse_memberdef(md, "group__s", SPEC, API, source_pages=pages) + assert info["source_file"] == "t.c (line 14)" + assert info["source_url"] == f"{SPEC}/t_8c_source.html#l00014" + assert info["doxygen_url"] == f"{SPEC}/group__s.html#a1" + assert dp.parse_memberdef(md, "group__s", SPEC, API)["source_url"] == "" diff --git a/sphinx/_scripts/_tests/test_rst_builders.py b/sphinx/_scripts/_tests/test_rst_builders.py index d68f948..a6d7c81 100644 --- a/sphinx/_scripts/_tests/test_rst_builders.py +++ b/sphinx/_scripts/_tests/test_rst_builders.py @@ -303,6 +303,15 @@ def test_the_references_of_a_test_case_are_in_the_container_in_order(): assert rst.index("The queue keeps the order.") < rst.index(".. container:: need-refs") +def test_the_source_of_a_test_case_is_its_listing_if_there_is_one(): + info = _base_info(source_url="/testspec/html/test__queue_8c_source.html#l00042") + _, refs = _container(rb.build_need_rst(info, "kernel.queue")) + assert refs == [ + "**Source:** `test_queue.c (line 42) " + "`__", + ] + + def test_a_test_case_without_references_has_no_container(): rst = rb.build_need_rst(_base_info(source_file="", doxygen_url=""), "kernel.queue") assert "need-refs" not in rst diff --git a/sphinx/_scripts/doxygen_parser.py b/sphinx/_scripts/doxygen_parser.py index e73e827..43dada8 100644 --- a/sphinx/_scripts/doxygen_parser.py +++ b/sphinx/_scripts/doxygen_parser.py @@ -27,6 +27,8 @@ "SymbolInfo", "parse_symbol", "load_group_index", + "SourcePages", + "member_source", ] @@ -36,6 +38,7 @@ class MemberInfo(TypedDict): req_ids: list[str] status: str source_file: str + source_url: str doxygen_url: str brief: str detail_lines: list[str] @@ -51,6 +54,7 @@ class SymbolInfo(TypedDict): satisfies: list[str] brief: str source_file: str + source_url: str doxygen_url: str depends_on: list[str] depends_label: str @@ -437,21 +441,18 @@ def parse_memberdef( testspec_html_dir: str, api_html_dir: str, tag_dirs: Mapping[str, str] | None = None, + source_pages: "SourcePages | None" = None, ) -> MemberInfo: """Extract all structured fields from a element — name, source location, Doxygen URL, brief description, test ID, requirement refs, status, see-also, and Arrange/Act/Assert body sections — and return them as a MemberInfo. - ``tag_dirs``: where a tag-file reference links, by tag file (`RefLinks.tags`).""" + ``tag_dirs``: where a tag-file reference links, by tag file (`RefLinks.tags`). + ``source_pages``: the source listings of the run (`SourcePages`); with them, + ``source_url`` is the listing of the function's file at its first line.""" name = memberdef.findtext("name", "").strip() - loc = memberdef.find("location") - source_file = "" - if loc is not None: - fpath = loc.get("bodyfile") or loc.get("file", "") - line = loc.get("bodystart") or loc.get("line", "") - if fpath: - source_file = f"{Path(fpath).name} (line {line})" + source_file, source_url = member_source(memberdef, testspec_html_dir, source_pages) member_id = memberdef.get("id", "") prefix = compound_id + "_1" @@ -538,6 +539,7 @@ def parse_memberdef( req_ids=req_ids, status=status, source_file=source_file, + source_url=source_url, doxygen_url=doxygen_url, brief=brief, detail_lines=detail_lines, @@ -602,10 +604,13 @@ def requirement_uids(memberdef: ET.Element, relation: str) -> list[str]: return uids -def parse_symbol(memberdef: ET.Element, html_dir: str) -> SymbolInfo: +def parse_symbol( + memberdef: ET.Element, html_dir: str, source_pages: "SourcePages | None" = None +) -> SymbolInfo: """An API symbol (function, macro, ...) as a need's data: name, kind, the requirements it satisfies, its brief, where it is declared, and its page in - the Doxygen HTML under ``html_dir``. + the Doxygen HTML under ``html_dir``. With ``source_pages``, ``source_url`` + is the declaration's line in the source listing. The page is the one Doxygen documents the member on, which its id names: ``_1``, the same shape `ref_to_rst` links. A member in a @@ -618,12 +623,14 @@ def parse_symbol(memberdef: ET.Element, html_dir: str) -> SymbolInfo: doxygen_url = f"{html_dir}/{member_id[:cut]}.html#{member_id[cut + 2:]}" loc = memberdef.find("location") - source_file = "" + source_file = source_url = "" if loc is not None: fpath = loc.get("declfile") or loc.get("file", "") line = loc.get("declline") or loc.get("line", "") if fpath: source_file = f"{fpath} (line {line})" if line else fpath + if source_pages is not None: + source_url = source_pages.url(html_dir, fpath, line) depends_label, depends_on = kconfig_depends(memberdef.find("detaileddescription")) return SymbolInfo( @@ -632,12 +639,137 @@ def parse_symbol(memberdef: ET.Element, html_dir: str) -> SymbolInfo: satisfies=requirement_uids(memberdef, "satisfies"), brief=para_text(memberdef.find("briefdescription/para"), RefLinks(local=html_dir)), source_file=source_file, + source_url=source_url, doxygen_url=doxygen_url, depends_on=depends_on, depends_label=depends_label, ) +def member_source(memberdef, html_dir, source_pages=None): + """``(source_file, source_url)`` of a function's body: ``"main.c (line 290)"`` + and its line in the source listing under ``html_dir``, or ``""`` without one.""" + loc = memberdef.find("location") + if loc is None: + return "", "" + fpath = loc.get("bodyfile") or loc.get("file", "") + line = loc.get("bodystart") or loc.get("line", "") + if not fpath: + return "", "" + url = source_pages.url(html_dir, fpath, line) if source_pages is not None else "" + return f"{Path(fpath).name} (line {line})", url + + +#: Doxygen writes no listing for a documentation file. +_DOC_FILES = (".dox", ".md", ".markdown", ".txt", ".doc") +#: The files that ``VERBATIM_HEADERS`` gives a listing. +_HEADERS = (".h", ".hh", ".hxx", ".hpp", ".h++") + + +def _same_tail(a: str, b: str) -> bool: + """One path is the other with more directories in front.""" + return a == b or a.endswith("/" + b) or b.endswith("/" + a) + + +class SourcePages: + """The source listings of a Doxygen run, from its XML output. + + Doxygen writes the listing of a file as ``_source.html``, with + an anchor ``l`` per line: of every code file with + ``SOURCE_BROWSER = YES``, of every header with ``VERBATIM_HEADERS = YES`` + (the default). The XML does not name that page, so this class takes the + refid of the file compound whose ```` is the path, as the + member's ```` gives it. Doxygen's file name escaping is not + rebuilt. + + The settings come from the ``Doxyfile.xml`` that Doxygen writes beside + the XML. Without that file, `url` gives ``""``. A file compound's XML is + read only when a member of a file with its name asks for it. + + `inputs` holds every XML file read so far, to note as the inputs of a + document after its lookups. + """ + + def __init__(self, xml_dir: Path): + self.xml_dir = Path(xml_dir) + self._by_name: dict[str, list[str]] = {} + self._paths: dict[str, str] = {} # refid -> + #: Doxyfile.xml and index.xml (also when missing: a later run can + #: write them) and each file compound read by a lookup. + self.inputs: set[Path] = {self.xml_dir / "Doxyfile.xml", self.xml_dir / "index.xml"} + options = self._options() + if options is None: + return + self._all = options.get("SOURCE_BROWSER", "NO") == "YES" + self._headers = options.get("VERBATIM_HEADERS", "YES") == "YES" + try: + root = ET.parse(self.xml_dir / "index.xml").getroot() + except (OSError, ET.ParseError): + return + for c in root.findall("compound[@kind='file']"): + name, refid = c.findtext("name"), c.get("refid") + if name and refid and self._listed(name): + self._by_name.setdefault(name, []).append(refid) + + def _options(self) -> dict[str, str] | None: + try: + root = ET.parse(self.xml_dir / "Doxyfile.xml").getroot() + except (OSError, ET.ParseError): + return None + return { + o.get("id"): (o.findtext("value") or "").strip().upper() + for o in root.iter("option") + } + + def _listed(self, name: str) -> bool: + low = name.lower() + if low.endswith(_DOC_FILES): + return False + return self._all or (self._headers and low.endswith(_HEADERS)) + + def _path_of(self, refid: str) -> str: + if refid not in self._paths: + path = "" + xml = self.xml_dir / f"{refid}.xml" + self.inputs.add(xml) + try: + cdef = ET.parse(xml).getroot().find("compounddef") + loc = cdef.find("location") if cdef is not None else None + path = loc.get("file", "") if loc is not None else "" + except (OSError, ET.ParseError): + pass + self._paths[refid] = path + return self._paths[refid] + + def refid(self, path: str, tail: bool = False) -> str: + """The refid of the file compound of ``path``, or ``""``. + + ``tail``: ``path`` is from another root than Doxygen's paths + (``kernel/thread.c`` for ``zephyr/kernel/thread.c``). One path must + then end with the other, and only one file may match. + """ + candidates = self._by_name.get(Path(path).name, ()) + for refid in candidates: + if self._path_of(refid) == path: + return refid + if tail: + found = [r for r in candidates if _same_tail(self._path_of(r), path)] + if len(found) == 1: + return found[0] + return "" + + def url(self, html_dir: str, path: str, line, tail: bool = False) -> str: + """``/_source.html#l00290``, or ``""`` without a listing.""" + refid = self.refid(path, tail) + if not refid or not html_dir: + return "" + try: + anchor = f"#l{int(line):05d}" + except (TypeError, ValueError): + anchor = "" + return f"{html_dir}/{refid}_source.html{anchor}" + + def load_group_index(xml_dir: Path) -> dict[str, str]: """Parse Doxygen's index.xml and return a dict mapping each group's name to its refid, which is used as the filename stem for the group's XML file.""" diff --git a/sphinx/_scripts/rst_builders.py b/sphinx/_scripts/rst_builders.py index e320450..af25630 100644 --- a/sphinx/_scripts/rst_builders.py +++ b/sphinx/_scripts/rst_builders.py @@ -178,8 +178,10 @@ def build_need_rst( lines.append("") refs = list(depends_body) - if source_file and doxygen_url: - refs.append(f"**Source:** `{source_file} <{doxygen_url}>`__") + # The source listing at the line, else the member's documentation page. + source_link = info.get("source_url") or doxygen_url + if source_file and source_link: + refs.append(f"**Source:** `{source_file} <{source_link}>`__") elif source_file: refs.append(f"**Source:** {source_file}") if see_rst: @@ -197,15 +199,19 @@ def build_procedure_need_rst( api_html_dir, need_names=None, tag_dirs=None, + source_pages=None, ): """Build a test_procedure needs item for one shared test procedure. ``tag_dirs``: where a tag-file reference links (`doxygen_parser.RefLinks.tags`). + ``source_pages``: the source listings (`doxygen_parser.SourcePages`); with + them, Source links to the listing at the line. """ from doxygen_parser import ( RefLinks, detail_rst_lines, extract_params, + member_source, para_text, see_to_rst, ) @@ -219,18 +225,12 @@ def build_procedure_need_rst( if not title: title = name - loc = memberdef.find("location") - source_file = "" - if loc is not None: - fpath = loc.get("bodyfile") or loc.get("file", "") - line = loc.get("bodystart") or loc.get("line", "") - if fpath: - source_file = f"{Path(fpath).name} (line {line})" + source_file, source_url = member_source(memberdef, testspec_html_dir, source_pages) member_id = memberdef.get("id", "") prefix = proc_compound_id + "_1" anchor = member_id[len(prefix) :] if member_id.startswith(prefix) else member_id - doxygen_url = f"{testspec_html_dir}/{proc_compound_id}.html#{anchor}" + doxygen_url = source_url or f"{testspec_html_dir}/{proc_compound_id}.html#{anchor}" dd = memberdef.find("detaileddescription") detail_lines = [] @@ -397,7 +397,9 @@ def build_symbol_need_rst(info, need_names=None, depends_field=False): head = f"`{kind.capitalize()} {name} <{info['doxygen_url']}>`__" lines += [f" {head}" + (f" — {info['brief']}" if info["brief"] else ""), ""] refs = list(depends_body) - if info["source_file"]: + if info["source_file"] and info.get("source_url"): + refs.append(f"**Declared in:** `{info['source_file']} <{info['source_url']}>`__") + elif info["source_file"]: refs.append(f"**Declared in:** ``{info['source_file']}``") lines += _refs_rst(refs) return "\n".join(lines) diff --git a/sphinx/_static/zdocs-needs.css b/sphinx/_static/zdocs-needs.css index 7effdd5..1c4df39 100644 --- a/sphinx/_static/zdocs-needs.css +++ b/sphinx/_static/zdocs-needs.css @@ -315,7 +315,7 @@ table.need.need.need[class*=" needs_layout_zdocs"] tr.meta :is(.needs_test_funct /* * The body table of an adequacy need (testcoverage): one row per body of a * satisfying symbol. The roles zd-adq-ok / -miss / -none give the lines that - * the own tests ran as a chip; zd-adq-loc is the form and place of the body. + * the own tests ran as a chip; p.zd-adq-loc is the form and place of the body. */ html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies { width: 100%; @@ -361,7 +361,7 @@ html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs" font-size: 0.95em; font-weight: 600; } -table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-loc { +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies td p.zd-adq-loc { color: var(--zd-label); font-family: var(--zd-mono); font-size: 0.85em; diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 32599f4..13b8485 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -427,11 +427,18 @@ def configure( # Consumed by the cross-document navigation in the page template. namespace["html_context"] = {"reference_groups": refs.reference_groups} + if refs is not None: + # The XML and HTML of each Doxygen document, by registry id + # (testcoverage_source_docs names some of them). + namespace["zdocs_doxygen_docs"] = refs.doxygen_docs if testmodule is not None: namespace["testmodule_xml_dir"] = testmodule["xml_dir"] namespace["testspec_doxygen_url"] = testmodule["doxygen_url"] namespace["api_doxygen_url"] = testmodule["api_url"] namespace["testmodule_tag_urls"] = testmodule.get("tag_urls", {}) + # The registry's testmodule.coverage_source_docs, so the build also + # waits for these Doxygen documents. + namespace["testcoverage_source_docs"] = testmodule.get("coverage_source_docs", []) namespace["testspec_needs_json"] = testmodule["needs_json"] namespace["testmodule_root"] = str(project_base) if project_base else "" namespace["twister_output_dir"] = os.environ.get("ZDOCS_TWISTER_OUT", "")