# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0
"""RST string builders — no Sphinx dependency."""
# This file emits sphinx-needs directive/link names for the `case` /
# `procedure` / `result` / `implementation` need-type roles and the `verifies`
# / `result_of` / `covers` / `satisfies` link roles via the `need_names`
# role->name mapping (zdocs step 26, zdocs-design-twister.md §12), defaulting
# to the literal names below when a role is absent from the mapping.
import logging
import re
from collections import Counter
from pathlib import Path
import yaml
__all__ = [
"slugify",
"build_need_rst",
"build_procedure_need_rst",
"build_result_rst",
"build_symbol_need_rst",
"build_scenario_table",
"SCENARIO_YAML_NAMES",
"find_scenario_yaml",
]
logger = logging.getLogger(__name__)
[docs]
def slugify(s):
"""Replace non-alphanumeric runs with '-' and strip leading/trailing dashes."""
return re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-")
# Engine ROLES -> today's literal sphinx-needs NAMES. A consumer overrides
# any subset of these via `testmodule_need_types` / `testmodule_need_links`
# (merged by the caller into the single `need_names` dict threaded through
# below); a role missing from `need_names` falls back to its default here.
_DEFAULT_NEED_NAMES = {
"case": "test_case",
"procedure": "test_procedure",
"result": "test_result",
"verifies": "verifies",
"result_of": "result_of",
"covers": "covers",
# symbolneeds: an API symbol, and the requirements it satisfies.
"implementation": "impl",
"satisfies": "satisfies",
# testreport: result FIELDS, named by the consumer too
# (`testreport_need_fields`). Whether the build met the case's
# depends_on, and why a skipped result was skipped.
"depends_met": "depends_met",
"skip_class": "skip_class",
# testcoverage: one adequacy need per requirement and coverage run, its
# link to the requirement, and its fields (`testcoverage_need_*`).
"adequacy": "adequacy",
"assesses": "assesses",
"verdict": "verdict",
"evidence": "evidence",
"coverage_run": "coverage_run",
"judged_symbols": "judged_symbols",
"symbol_hits": "symbol_hits",
}
#: The result-field roles `build_result_rst` can set (see its ``fields``).
RESULT_FIELD_ROLES = ("depends_met", "skip_class")
def _need_name(need_names, role):
"""Resolve a need-type/link ROLE to its consumer-configured NAME."""
if need_names and role in need_names:
return need_names[role]
return _DEFAULT_NEED_NAMES[role]
def _depends_on_rst(info, depends_field):
"""``(option lines, body lines)`` for a need's Kconfig conditions.
The body line always renders, labelled as the consumer's alias titles the
xrefitem ("Depends on"), because a consumer's need layout decides which
fields show. The ``depends_on`` field is set only when ``depends_field``
says the consumer declared it: an undeclared option is an "Unknown option"
warning per need. The conditions are joined with ``"; "``, which no Kconfig
expression contains.
"""
conditions = info.get("depends_on") or []
if not conditions:
return [], []
options = [f" :depends_on: {'; '.join(conditions)}"] if depends_field else []
label = info.get("depends_label") or "Depends on"
body = [f" **{label}:** " + "; ".join(f"``{c}``" for c in conditions), ""]
return options, body
[docs]
def build_need_rst(
info, suite_name, module_path="", suite_title="", need_names=None, depends_field=False,
id_scope=None,
):
"""Build the RST block for a single test_case need.
``depends_field``: set the ``depends_on`` field (see `_depends_on_rst`).
``id_scope``: what the fallback id ``testspec-<scope>-<function>`` is scoped
by — the suite's Doxygen group name, which differs from ``suite_name`` under
a `testmodule_suite_qualifier`; defaults to ``suite_name``.
"""
name = info["name"]
test_id = info["test_id"]
req_ids = info["req_ids"]
status = info["status"]
source_file = info["source_file"]
doxygen_url = info["doxygen_url"]
brief = info["brief"]
detail_lines = info.get("detail_lines", [])
see_rst = info["see_rst"]
body_sections = info["body_sections"]
stem = name[5:] if name.startswith("test_") else name
title = stem.replace("_", " ")
if test_id:
need_id = test_id
else:
scope = id_scope or suite_name
need_id = f"testspec-{scope}-{name}"
logger.warning(f"testmodule: {scope}/{name} has no @testid annotation")
lines = [f".. {_need_name(need_names, 'case')}:: {title}"]
lines.append(f" :id: {need_id}")
lines.append(f" :test_function: {name}")
if module_path:
lines.append(f" :test_module: {module_path}")
lines.append(f" :suite: {suite_name}")
if suite_title:
lines.append(f" :suite_title: {suite_title}")
lines.append(f" :status: {status}")
if req_ids:
lines.append(f" :{_need_name(need_names, 'verifies')}: {'; '.join(req_ids)}")
depends_options, depends_body = _depends_on_rst(info, depends_field)
lines += depends_options
lines.append("")
if brief:
lines.append(" .. rst-class:: need-brief")
lines.append("")
lines.append(f" {brief}")
lines.append("")
# Indented PER LINE, blank lines preserved as blanks — `detail_lines` is RST
# lines (prose and lists), not one string per paragraph. Same shape as
# `body_sections` below; indenting only the first line of a block would turn
# a bullet list into a docutils "unexpected unindent".
if detail_lines:
for dline in detail_lines:
lines.append(f" {dline}" if dline else "")
lines.append("")
for section_lines in body_sections:
for sline in section_lines:
lines.append(f" {sline}" if sline else "")
lines.append("")
lines += depends_body
if source_file and doxygen_url:
lines.append(f" **Source:** `{source_file} <{doxygen_url}>`__")
lines.append("")
elif source_file:
lines.append(f" **Source:** {source_file}")
lines.append("")
if see_rst:
lines.append(f" {see_rst}")
lines.append("")
return "\n".join(lines)
[docs]
def build_procedure_need_rst(
memberdef,
proc_compound_id,
proc_group_name,
testspec_html_dir,
api_html_dir,
need_names=None,
tag_dirs=None,
):
"""Build a test_procedure needs item for one shared test procedure.
``tag_dirs``: where a tag-file reference links (`doxygen_parser.RefLinks.tags`).
"""
from doxygen_parser import (
RefLinks,
detail_rst_lines,
extract_params,
para_text,
see_to_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:
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})"
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}"
dd = memberdef.find("detaileddescription")
detail_lines = []
params = []
see_rst_str = ""
if dd is not None:
links = RefLinks(api=api_html_dir, local=testspec_html_dir, tags=tag_dirs)
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, links)
# Every see section, not only the first: see `see_to_rst`.
see_sects = dd.findall(".//simplesect[@kind='see']")
if see_sects:
see_rst_str = see_to_rst(see_sects, api_html_dir, testspec_html_dir, tag_dirs)
lines = []
lines.append(f".. {_need_name(need_names, 'procedure')}:: {title}")
lines.append(f" :id: {need_id}")
lines.append(" :status: active")
lines.append("")
if detail_lines:
for dline in detail_lines:
lines.append(f" {dline}" if dline else "")
lines.append("")
if params:
for pname, pdesc in params:
lines.append(f" :``{pname}``: {pdesc}")
lines.append("")
if source_file and doxygen_url:
lines.append(
f" **Source:** :c:func:`{name}` — `{source_file} <{doxygen_url}>`__"
)
lines.append("")
if see_rst_str:
lines.append(f" {see_rst_str}")
lines.append("")
return "\n".join(lines)
[docs]
def build_result_rst(r, spec_id, test_module, req_ids=None, need_names=None, fields=()):
"""Build RST block for one test_result need.
``fields``: the result-field roles (`RESULT_FIELD_ROLES`) to set, under
their names from ``need_names``, where ``r`` has a value for them — the
ones the consumer declared; an undeclared option warns per need.
"""
need_id = f"TR-{slugify(r['platform'])}-{slugify(r['scenario'])}-{spec_id}"
fn = r["function"]
title = (fn[5:] if fn.startswith("test_") else fn).replace("_", " ")
lines = [
f".. {_need_name(need_names, 'result')}:: {title}",
f" :id: {need_id}",
f" :status: {r['status']}",
]
if test_module:
lines.append(f" :test_module: {test_module}")
lines += [
f" :platform: {r['platform']}",
f" :scenario: {r['scenario']}",
f" :twister_id: {r['twister_id']}",
f" :execution_time: {r['time']}",
f" :{_need_name(need_names, 'result_of')}: {spec_id}",
]
if req_ids:
lines.append(f" :{_need_name(need_names, 'covers')}: {'; '.join(req_ids)}")
if r["reason"]:
lines.append(f" :reason: {r['reason']}")
for role in RESULT_FIELD_ROLES:
if role in fields and r.get(role):
lines.append(f" :{_need_name(need_names, role)}: {r[role]}")
lines.append("")
if r.get("values"):
lines += _values_rst(r)
return "\n".join(lines)
def _values_summary(values):
"""``"9 values: 8 passed, 1 failed"`` for a parameterized test's values."""
counts = Counter(v["status"] for v in values)
order = ["passed", "failed", "error", "skipped"]
parts = [f"{counts[k]} {k}" for k in order if counts[k]]
parts += [f"{n} {k}" for k, n in sorted(counts.items()) if k not in order]
return f"{len(values)} values: {', '.join(parts)}"
_RST_INLINE = re.compile(r"([\\`*_|\[\]<>])")
def _rst_text(text):
"""``text`` as literal RST prose: inline markup characters escaped."""
return _RST_INLINE.sub(r"\\\1", " ".join(str(text).split()))
def _values_rst(r):
"""The body of a parameterized test's result: counts, then what did not pass.
Passed values are counted, not listed; a run of hundreds of values would
otherwise bury the one that failed.
"""
values = r["values"]
summary = _values_summary(values) + "."
if r.get("twister_status"):
summary += f" Twister reported the test as ``{r['twister_status']}``."
lines = [f" {summary}", ""]
others = [v for v in values if v["status"] != "passed"]
if others:
lines += [
" .. list-table:: Values not passed",
" :header-rows: 1",
" :widths: 30 15 55",
"",
" * - Value",
" - Status",
" - Reason",
]
for v in others:
lines += [
f" * - {_rst_text(v['value'])}",
f" - {v['status']}",
f" - {_rst_text(v['reason']) if v['reason'] else '—'}",
]
lines.append("")
return lines
def symbol_need_id(name, need_names=None):
"""The need id of API symbol ``name``: ``<TYPE>-<name>``, e.g. ``IMPL-k_queue_init``.
Prefixed with the consumer's own name for the need type, so the id says
what the need is in the project's vocabulary, and a symbol's need can never
collide with a requirement or test case of the same spelling.
"""
prefix = re.sub(r"[^A-Za-z0-9]+", "_", _need_name(need_names, "implementation")).upper()
return f"{prefix}-{name}"
[docs]
def build_symbol_need_rst(info, need_names=None, depends_field=False):
"""Build the RST block for one API symbol's need (the ``implementation`` role).
``info`` is a `doxygen_parser.parse_symbol` result. The requirements it
satisfies become the ``satisfies`` link, so each requirement's page lists
the symbol under the link's incoming name, beside its verifying test cases.
The kind, declaration and Doxygen page go in the body, where no field has
to be declared for them. ``depends_field``: set the ``depends_on`` field
(see `_depends_on_rst`).
"""
name = info["name"]
lines = [
f".. {_need_name(need_names, 'implementation')}:: {name}",
f" :id: {symbol_need_id(name, need_names)}",
]
if info["satisfies"]:
lines.append(f" :{_need_name(need_names, 'satisfies')}: {'; '.join(info['satisfies'])}")
depends_options, depends_body = _depends_on_rst(info, depends_field)
lines += depends_options
lines.append("")
kind = info["kind"] or "symbol"
head = f"{kind.capitalize()} ``{name}``"
if info["doxygen_url"]:
head = f"`{kind.capitalize()} {name} <{info['doxygen_url']}>`__"
lines += [f" {head}" + (f" — {info['brief']}" if info["brief"] else ""), ""]
lines += depends_body
if info["source_file"]:
lines += [f" **Declared in:** ``{info['source_file']}``", ""]
return "\n".join(lines)
# The names that twister reads for the scenarios of a test directory, in
# twister's order (scripts/pylib/twister/twisterlib/testplan.py). Newer Zephyr
# trees name the file tests.yaml, older ones testcase.yaml.
SCENARIO_YAML_NAMES = ("testcase.yaml", "tests.yaml", "sample.yaml")
[docs]
def find_scenario_yaml(module_dir):
"""Return the first scenario file in ``module_dir`` that exists, or None.
The names come from ``SCENARIO_YAML_NAMES``. When no file exists, log a
warning that names each file it tried.
"""
module_dir = Path(module_dir)
for name in SCENARIO_YAML_NAMES:
path = module_dir / name
if path.is_file():
return path
logger.warning(
f"testmodule: no scenario file in {module_dir} "
f"(tried {', '.join(SCENARIO_YAML_NAMES)})"
)
return None
[docs]
def build_scenario_table(testcase_yaml_path):
"""Return RST lines for a list-table of scenarios from a scenario file."""
try:
with open(testcase_yaml_path) as f:
data = yaml.safe_load(f)
except (OSError, yaml.YAMLError) as e:
logger.warning(f"testmodule: could not read {testcase_yaml_path}: {e}")
return []
scenarios = data.get("tests", {})
if not scenarios:
return []
heading = "Test Scenarios"
lines = [
heading,
"-" * len(heading),
"",
".. list-table:: Test Scenarios",
" :header-rows: 1",
" :widths: 30 25 45",
"",
" * - Scenario",
" - Tags",
" - Extra config",
]
for scenario_name, scenario_data in scenarios.items():
tags = ", ".join(scenario_data.get("tags", []))
extra = ", ".join(scenario_data.get("extra_configs", []))
lines.append(f" * - ``{scenario_name}``")
lines.append(f" - {tags}")
lines.append(f" - {extra or '—'}")
lines.append("")
return lines