[docs]   1# @Test suite for multi-line needs rendered by src-trace, TEST_MULTILINE_RENDER_1, test, [IMPL_LNK_1]
   2"""``src-trace`` creates a need from every multi-line need of its files (#1885).
   3
   4Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) made
   5into a git repository, with ``myst-parser`` loaded, ``get_multiline_needs`` on, a host
   6need ``REQ_HOST`` and a label ``host-label`` in ``index``, and ``src/refs.cpp`` emptied of
   7its references. A block's body is parsed in the markup it declares, whatever the page's:
   8the four cells (page markup by body markup) render the same role link, reference, list and literal.
   9"""
  10
  11import hashlib
  12import re
  13from pathlib import Path
  14from typing import Any
  15
  16import myst_parser
  17import pytest
  18from sphinx.testing.util import SphinxTestApp
  19from sphinx.util.parallel import parallel_available
  20
  21from sphinx_needs_testkit import build_warnings
  22
  23from .test_need_id_refs import _SHOWS_WARNING_TYPES, _build, _json, _MakeApp, _project
  24from .test_rediscovery import _touch_later
  25
  26MYST_MAJOR = int(myst_parser.__version__.split(".")[0])
  27
  28CONF = """\
  29extensions = ["sphinx_needs", "sphinx_codelinks", "myst_parser"]
  30exclude_patterns = ["_build"]
  31needs_build_json = True
  32source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
  33needs_fields = {"priority": {"description": "Priority", "schema": {"type": "integer"}}}
  34"""
  35
  36INDEX = """\
  37Multi-line needs
  38================
  39
  40.. toctree::
  41   :glob:
  42
  43   *
  44   sub/*
  45
  46.. req:: A host need
  47   :id: REQ_HOST
  48
  49.. _host-label:
  50
  51Host label target
  52-----------------
  53"""
  54
  55MULTILINE = """
  56[codelinks.projects.src.analyse]
  57get_oneline_needs = true
  58get_multiline_needs = true
  59"""
  60
  61#: the files every case starts from
  62BASE = {
  63    "docs/conf.py": CONF,
  64    "docs/index.rst": INDEX,
  65    "src/refs.cpp": "// no markers here\n",
  66}
  67
  68PAGE_RST = """\
  69RST page
  70========
  71
  72.. src-trace::
  73   :project: src
  74   :file: rr.c
  75
  76.. src-trace::
  77   :project: src
  78   :file: rm.c
  79"""
  80
  81PAGE_MD = """\
  82# MyST page
  83
  84```{src-trace}
  85:project: src
  86:file: mr.c
  87```
  88
  89```{src-trace}
  90:project: src
  91:file: mm.c
  92```
  93"""
  94
  95RST_BODY = """\
  96 * Body in RST, see :need:`REQ_HOST` and :ref:`host-label`.
  97 *
  98 * - bullet one
  99 * - bullet with ``inline literal``"""
 100
 101MYST_BODY = """\
 102 * Body in MyST, see {need}`REQ_HOST` and {ref}`host-label`.
 103 *
 104 * - bullet one
 105 * - bullet with `inline literal`"""
 106
 107
 108def _cell(name: str, markup: str, body: str) -> str:
 109    """A source file: a one-line need on line 1, a block opening on line 5."""
 110    upper = name.upper()
 111    return f"""\
 112// @one-line in {name}.c, IMPL_{upper}_ONE, impl, [REQ_HOST]
 113void f() {{}}
 114
 115/**
 116 * @need[{markup}] req: {upper} body
 117 * :id: REQ_{upper}
 118 * :links: REQ_HOST, IMPL_{upper}_ONE
 119 *
 120{body}
 121 * @endneed
 122 */
 123void g() {{}}
 124"""
 125
 126
 127#: the four cells: (source, page, the directive's line on it, the body's markup)
 128CELLS = {
 129    "REQ_RR": ("rr.c", "page_rst", 4, ".rst"),
 130    "REQ_RM": ("rm.c", "page_rst", 8, ".md"),
 131    "REQ_MR": ("mr.c", "page_md", 3, ".rst"),
 132    "REQ_MM": ("mm.c", "page_md", 8, ".md"),
 133}
 134
 135FOUR_CELLS = {
 136    "docs/page_rst.rst": PAGE_RST,
 137    "docs/page_md.md": PAGE_MD,
 138    "src/rr.c": _cell("rr", "rst", RST_BODY),
 139    "src/rm.c": _cell("rm", "md", MYST_BODY),
 140    "src/mr.c": _cell("mr", "rst", RST_BODY),
 141    "src/mm.c": _cell("mm", "md", MYST_BODY),
 142}
 143
 144
 145def _blocks(tmp_path: Path, files: dict[str, str], *, toml_extra: str = "") -> str:
 146    """The project with ``files`` added; return its commit."""
 147    return _project(
 148        tmp_path, files={**BASE, **files}, toml_extra=MULTILINE + toml_extra
 149    )
 150
 151
 152def _warning(location: str, message: str, subtype: str) -> str:
 153    suffix = f" [codelinks.{subtype}]" if _SHOWS_WARNING_TYPES else ""
 154    return f"{location}: WARNING: {message}{suffix}"
 155
 156
 157def _multiline(location: str, message: str) -> str:
 158    return _warning(location, message, "multiline_need")
 159
 160
 161def _content_html(app: SphinxTestApp, page: str, need_id: str) -> str:
 162    """The HTML of the content cell of the need ``need_id``."""
 163    text = Path(app.outdir, page).read_text(encoding="utf-8")
 164    start = text.index('<td class="need content"', text.index(f'id="{need_id}"'))
 165    return text[start : text.index("</td>", start)]
 166
 167
 168def _need(app: SphinxTestApp, need_id: str) -> dict[str, Any] | None:
 169    return _json(app)["needs"].get(need_id)
 170
 171
 172def _card(app: SphinxTestApp, page: str, need_id: str) -> str | None:
 173    """The HTML of the need's table on ``page``, ``None`` without one."""
 174    html = Path(app.outdir, page).read_text(encoding="utf-8")
 175    match = re.search(rf'<table class="[^"]*" id="{need_id}"[^>]*>', html)
 176    return (
 177        None
 178        if match is None
 179        else html[match.start() : html.index("</table>", match.start())]
 180    )
 181
 182
 183def _warnings(app: SphinxTestApp) -> list[str]:
 184    """:func:`build_warnings` without the extension re-registration notes a second
 185    application in one process logs."""
 186    return [w for w in build_warnings(app) if "is already registered" not in w]
 187
 188
 189def test_four_cells_render_in_the_declared_markup(
 190    tmp_path: Path, make_app: _MakeApp
 191) -> None:
 192    """An RST and a MyST body, each in an RST and a MyST page: the same role link,
 193    reference, list and literal in all four; each need recorded at its directive with
 194    the body's doctype, its links converted, its URLs at the block's open line."""
 195    commit = _blocks(tmp_path, FOUR_CELLS)
 196    app = _build(tmp_path, make_app)
 197
 198    assert build_warnings(app) == []
 199    for need_id, (source, page, lineno, doctype) in CELLS.items():
 200        need = _need(app, need_id)
 201        assert need is not None, need_id
 202        upper = need_id.removeprefix("REQ_")
 203        assert {
 204            key: need[key]
 205            for key in ("docname", "lineno", "doctype", "links", "is_import")
 206        } == {
 207            "docname": page,
 208            "lineno": lineno,
 209            "doctype": doctype,
 210            # Sphinx-Needs keeps a need's links sorted
 211            "links": [f"IMPL_{upper}_ONE", "REQ_HOST"],
 212            "is_import": False,
 213        }, need_id
 214        assert need["local-url"] == f"src/{source}#L5"
 215        assert need["remote-url"] == (
 216            f"https://github.com/example/demo/blob/{commit}/src/{source}#L5"
 217        )
 218        html = _content_html(app, f"{page}.html", need_id)
 219        assert 'href="index.html#REQ_HOST"' in html, need_id
 220        assert 'href="index.html#host-label"' in html, need_id
 221        assert '<ul class="simple">' in html, need_id
 222        assert '<code class="docutils literal notranslate">' in html, need_id
 223        # no role written in the other markup is left as text
 224        assert ":need:" not in html, need_id
 225        assert "{need}" not in html, need_id
 226        # the source page links the open line back to the need
 227        source_page = Path(app.outdir, "src", source.replace(".c", ".html"))
 228        assert (
 229            f'<a class="viewcode-back" href="../{page}.html#{need_id}">[docs]</a>'
 230            '<a id="L-5" name="L-5">'
 231        ) in source_page.read_text(encoding="utf-8"), need_id
 232
 233
 234#: a second project, ``ts``, beside the fixture's ``src``
 235TS_PROJECT = """
 236[codelinks.projects.ts]
 237remote_url_pattern = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"
 238
 239[codelinks.projects.ts.source_discover]
 240src_dir = "../ts"
 241comment_type = "ts"
 242
 243[codelinks.projects.ts.analyse]
 244get_oneline_needs = true
 245get_multiline_needs = true
 246"""
 247
 248TS_FILES = {
 249    "docs/page_ts.rst": """\
 250TypeScript page
 251===============
 252
 253.. src-trace::
 254   :project: ts
 255   :file: handler.ts
 256
 257.. src-trace::
 258   :project: ts
 259   :file: button.tsx
 260""",
 261    # a run of // comments, its body in RST, opening on line 4
 262    "ts/handler.ts": """\
 263// @handler entry, IMPL_TS_ONE, impl, [REQ_HOST]
 264export function entry(): void {}
 265
 266// @need[rst] req: Handler body
 267// :id: REQ_TS_HANDLER
 268// :links: REQ_HOST, IMPL_TS_ONE
 269//
 270// Body in RST, see :need:`REQ_HOST`.
 271//
 272// - item one
 273// - item two
 274// @endneed
 275export function handle(request: Request): Response {
 276  return new Response();
 277}
 278""",
 279    # a JSDoc block, its body in MyST, opening on line 5
 280    "ts/button.tsx": """\
 281// @button entry, IMPL_TSX_ONE, impl, [REQ_HOST]
 282export const entry = 1;
 283
 284/**
 285 * @need[md] req: Button body
 286 * :id: REQ_TSX_BUTTON
 287 * :links: REQ_HOST, IMPL_TSX_ONE
 288 *
 289 * Body in MyST, see {need}`REQ_HOST`.
 290 *
 291 * - item one
 292 * - item two
 293 * @endneed
 294 */
 295export const LoginButton = () => <button>Log in</button>;
 296""",
 297}
 298
 299
 300def test_typescript_blocks_render(tmp_path: Path, make_app: _MakeApp) -> None:
 301    """A ``//`` run in a ``.ts`` file and a JSDoc block in a ``.tsx`` file render
 302    through ``src-trace`` as the C blocks do: each body in its declared markup, the
 303    need recorded at its directive, its URL at the block's open line."""
 304    _blocks(tmp_path, TS_FILES, toml_extra=TS_PROJECT)
 305    app = _build(tmp_path, make_app)
 306
 307    assert build_warnings(app) == []
 308    expected = {
 309        "REQ_TS_HANDLER": ("handler.ts", 4, ".rst", "IMPL_TS_ONE", 4),
 310        "REQ_TSX_BUTTON": ("button.tsx", 8, ".md", "IMPL_TSX_ONE", 5),
 311    }
 312    for need_id, (source, lineno, doctype, one_line, open_line) in expected.items():
 313        need = _need(app, need_id)
 314        assert need is not None, need_id
 315        assert {
 316            key: need[key] for key in ("docname", "lineno", "doctype", "links")
 317        } == {
 318            "docname": "page_ts",
 319            "lineno": lineno,
 320            "doctype": doctype,
 321            # Sphinx-Needs keeps a need's links sorted
 322            "links": [one_line, "REQ_HOST"],
 323        }, need_id
 324        assert need["local-url"] == f"ts/{source}#L{open_line}"
 325        html = _content_html(app, "page_ts.html", need_id)
 326        assert 'href="index.html#REQ_HOST"' in html, need_id
 327        assert '<ul class="simple">' in html, need_id
 328        assert ":need:" not in html, need_id
 329        assert "{need}" not in html, need_id
 330
 331
 332def _at(*parts: str) -> str:
 333    """A path below the project root as a warning prints it after ``<srcdir>/``: with
 334    the OS's separators, since Sphinx and docutils print the file's own path."""
 335    return str(Path(*parts))
 336
 337
 338def _myst_logged(page: str, source: str, line: int) -> str:
 339    """Where a warning myst-parser logs itself points, for a body in ``source``:
 340    myst-parser 5 names the page being read with the body's line, myst-parser 4 the
 341    body's file with an ``.rst`` suffix (Sphinx-Needs' documented first-slice gap)."""
 342    if MYST_MAJOR >= 5:
 343        return f"<srcdir>/{_at('docs', page)}:{line}"
 344    return f"<srcdir>/{_at('src', source)}.rst:{line}"
 345
 346
 347def _diagnostics_cell(name: str, markup: str) -> str:
 348    upper = name.upper()
 349    if markup == "rst":
 350        body = " * A bad role :nosuchrole:`x` here.\n *\n * .. nosuchdirective:: arg"
 351    else:
 352        body = " * A bad role {nosuchrole}`x` here.\n *\n * ```{nosuchdirective} arg\n * ```"
 353    return f"""\
 354// a file with one block, opening on line 3
 355
 356/**
 357 * @need[{markup}] spec: {upper} diagnostics
 358 * :id: SPEC_{upper}
 359 *
 360{body}
 361 * @endneed
 362 */
 363void h() {{}}
 364"""
 365
 366
 367def test_body_diagnostics_name_the_source_line(
 368    tmp_path: Path, make_app: _MakeApp
 369) -> None:
 370    """A message about an RST body names the source file and line (absolute: Sphinx
 371    makes a node's source absolute); myst-parser's own messages about a MyST body name
 372    the page (myst-parser 5) or ``<source>.rst`` (4), with the source line."""
 373    _blocks(
 374        tmp_path,
 375        {
 376            "docs/page_rst.rst": PAGE_RST,
 377            "docs/page_md.md": PAGE_MD,
 378            "src/rr.c": _diagnostics_cell("rr", "rst"),
 379            "src/rm.c": _diagnostics_cell("rm", "md"),
 380            "src/mr.c": _diagnostics_cell("mr", "rst"),
 381            "src/mm.c": _diagnostics_cell("mm", "md"),
 382        },
 383    )
 384    app = _build(tmp_path, make_app)
 385
 386    docutils = " [docutils]" if _SHOWS_WARNING_TYPES else ""
 387
 388    def myst(kind: str) -> str:
 389        return f" [myst.{kind}]" if _SHOWS_WARNING_TYPES else ""
 390
 391    def rst(source: str) -> list[str]:
 392        return [
 393            f"<srcdir>/{_at('src', source)}:7: ERROR: Unknown interpreted text role "
 394            f'"nosuchrole".{docutils}',
 395            f"<srcdir>/{_at('src', source)}:9: ERROR: Unknown directive type "
 396            '"nosuchdirective".'
 397            "\n\n.. nosuchdirective:: arg"
 398            f"{docutils}",
 399        ]
 400
 401    def md(page: str, source: str) -> list[str]:
 402        return [
 403            f"{_myst_logged(page, source, 7)}: WARNING: Unknown interpreted text role "
 404            f'"nosuchrole".{myst("role_unknown")}',
 405            f"{_myst_logged(page, source, 9)}: WARNING: Unknown directive type: "
 406            f"'nosuchdirective'{myst('directive_unknown')}",
 407        ]
 408
 409    assert sorted(build_warnings(app, srcdir=tmp_path)) == sorted(
 410        [
 411            *rst("mr.c"),
 412            *md("page_md.md", "mm.c"),
 413            *rst("rr.c"),
 414            *md("page_rst.rst", "rm.c"),
 415        ]
 416    )
 417    for need_id in ("SPEC_RR", "SPEC_RM", "SPEC_MR", "SPEC_MM"):
 418        assert _need(app, need_id) is not None, need_id
 419
 420
 421Q3_HEAD = "// @need req: Q3 block\n"
 422#: an expectation on the need's card on its page rather than on ``needs.json``: ``None``
 423#: for no card, else a text the card holds
 424CARD = "<card>"
 425Q3_BODY = "//\n// Body {{ 1 + 1 }} here.\n// @endneed\nvoid q() {}\n"
 426
 427
 428@pytest.mark.parametrize(
 429    ("options", "expected", "warnings"),
 430    [
 431        pytest.param(
 432            [":links: REQ_HOST; REQ_002 | SPEC_001"],
 433            {"links": ["REQ_002", "REQ_HOST", "SPEC_001"]},
 434            [],
 435            id="links_split",
 436        ),
 437        pytest.param([":tags: a, b"], {"tags": ["a", "b"]}, [], id="tags_split"),
 438        pytest.param([":status: open"], {"status": "open"}, [], id="status"),
 439        pytest.param([":priority: 3"], {"priority": 3}, [], id="typed_field_coerced"),
 440        pytest.param(
 441            [":priority: notanint"],
 442            None,
 443            [
 444                (
 445                    1,
 446                    "invalid_field_value: multi-line need could not be created: Field "
 447                    "'priority' is invalid: Cannot convert 'notanint' to integer",
 448                )
 449            ],
 450            id="typed_field_refused",
 451        ),
 452        pytest.param(
 453            [":nosuch: x"],
 454            {"title": "Q3 block"},
 455            [
 456                (
 457                    3,
 458                    "multi-line need option 'nosuch' is not an option a multi-line "
 459                    "need takes: ignored",
 460                )
 461            ],
 462            id="unknown_option_warned",
 463        ),
 464        pytest.param([":hide:"], {CARD: None}, [], id="hide_flag"),
 465        pytest.param(
 466            [":collapse: true"], {CARD: "target__hide__meta"}, [], id="collapse"
 467        ),
 468        pytest.param(
 469            [":constraints: nosuchconstraint"],
 470            None,
 471            [
 472                (
 473                    1,
 474                    "invalid_constraints: multi-line need could not be created: "
 475                    "Constraints {'nosuchconstraint'} not in 'needs_constraints'.",
 476                )
 477            ],
 478            id="unknown_constraint_refused",
 479        ),
 480        pytest.param(
 481            [":jinja_content: false"],
 482            {"jinja_content": False, "content": "Body {{ 1 + 1 }} here."},
 483            [],
 484            id="jinja_content_false",
 485        ),
 486        pytest.param(
 487            [":jinja_content: true"],
 488            {"jinja_content": True, "content": "Body 2 here."},
 489            [],
 490            id="jinja_content_true",
 491        ),
 492        pytest.param(
 493            [":jinja_content: maybe"],
 494            None,
 495            [
 496                (
 497                    3,
 498                    "invalid_option: multi-line need could not be created: Invalid "
 499                    "value for 'jinja_content' option: not a flag or case-insensitive "
 500                    "true/false/yes/no",
 501                )
 502            ],
 503            id="jinja_content_refused",
 504        ),
 505        pytest.param(
 506            [":docname: elsewhere", ":lineno: 99"],
 507            {"docname": "page_q", "lineno": 4},
 508            [
 509                (
 510                    3,
 511                    "multi-line need option 'docname' is not an option a multi-line "
 512                    "need takes: ignored",
 513                ),
 514                (
 515                    4,
 516                    "multi-line need option 'lineno' is not an option a multi-line "
 517                    "need takes: ignored",
 518                ),
 519            ],
 520            id="computed_keys_warned",
 521        ),
 522        pytest.param(
 523            [":parts: some text"],
 524            {"parts": {}},
 525            [
 526                (
 527                    3,
 528                    "multi-line need option 'parts' is not an option a multi-line "
 529                    "need takes: ignored",
 530                )
 531            ],
 532            id="parts_warned_not_crashing",
 533        ),
 534        pytest.param(
 535            [":arch: some text"],
 536            {"arch": {}},
 537            [
 538                (
 539                    3,
 540                    "multi-line need option 'arch' is not an option a multi-line "
 541                    "need takes: ignored",
 542                )
 543            ],
 544            id="arch_warned",
 545        ),
 546        pytest.param(
 547            [":Status: open", ":TAGS: a, b"],
 548            {"status": "open", "tags": ["a", "b"]},
 549            [],
 550            id="option_keys_lowercased",
 551        ),
 552        pytest.param(
 553            [":status: open", ":Status: closed"],
 554            {"status": "open"},
 555            [
 556                (
 557                    4,
 558                    "multi-line need option 'Status' repeats 'status': the first "
 559                    "value is kept",
 560                )
 561            ],
 562            id="option_key_repeated_in_another_case",
 563        ),
 564        pytest.param(
 565            [":Type: spec", ":Content: x"],
 566            {"type": "req", "content": "Body {{ 1 + 1 }} here."},
 567            [
 568                (
 569                    3,
 570                    "multi-line need option 'Type' repeats the block's own 'type': ignored",
 571                ),
 572                (
 573                    4,
 574                    "multi-line need option 'Content' repeats the block's own "
 575                    "'content': ignored",
 576                ),
 577            ],
 578            id="option_naming_a_record_key_in_another_case",
 579        ),
 580        pytest.param(
 581            [":delete: true"],
 582            {"title": "Q3 block"},
 583            [
 584                (
 585                    3,
 586                    "multi-line need option 'delete' is not an option a multi-line "
 587                    "need takes: ignored",
 588                )
 589            ],
 590            id="directive_only_option_warned",
 591        ),
 592    ],
 593)
 594def test_options_are_the_need_directives(
 595    tmp_path: Path,
 596    make_app: _MakeApp,
 597    options: list[str],
 598    expected: dict[str, Any] | None,
 599    warnings: list[tuple[int, str]],
 600) -> None:
 601    """A block takes the options ``.. req::`` takes, converted as the directive converts
 602    them: links and tags split, typed fields coerced, ``jinja_content`` a flag. A key
 603    the directive does not know is ignored with a warning at its line, the need kept; a
 604    value Sphinx-Needs refuses refuses the need, warned at the open line."""
 605    source = Q3_HEAD + "// :id: REQ_Q3\n"
 606    source += "".join(f"// {option}\n" for option in options) + Q3_BODY
 607    _blocks(
 608        tmp_path,
 609        {
 610            "docs/page_q.rst": "Q\n=\n\n.. src-trace::\n   :project: src\n   :file: q.c\n",
 611            "src/q.c": source,
 612        },
 613    )
 614    app = _build(tmp_path, make_app)
 615
 616    assert build_warnings(app) == [
 617        _multiline(f"src/q.c:{line}", message) for line, message in warnings
 618    ]
 619    need = _need(app, "REQ_Q3")
 620    if expected is None:
 621        assert need is None
 622        return
 623    assert need is not None
 624    fields = {key: value for key, value in expected.items() if key != CARD}
 625    assert {key: need[key] for key in fields} == fields
 626    if CARD in expected:
 627        card = _card(app, "page_q.html", "REQ_Q3")
 628        if expected[CARD] is None:
 629            assert card is None
 630        else:
 631            assert card is not None
 632            assert expected[CARD] in card
 633
 634
 635def test_an_empty_id_is_refused_with_the_directives_message(
 636    tmp_path: Path, make_app: _MakeApp
 637) -> None:
 638    _blocks(
 639        tmp_path,
 640        {
 641            "docs/page_q.rst": "Q\n=\n\n.. src-trace::\n   :project: src\n   :file: q.c\n",
 642            "src/q.c": Q3_HEAD + "// :id:\n" + Q3_BODY,
 643        },
 644    )
 645    app = _build(tmp_path, make_app)
 646
 647    assert build_warnings(app) == [
 648        _multiline(
 649            "src/q.c:2",
 650            "invalid_option: multi-line need could not be created: Invalid value for "
 651            "'id' option: 'id' must not be empty",
 652        )
 653    ]
 654    assert not [
 655        need for need in _json(app)["needs"].values() if need["docname"] == "page_q"
 656    ]
 657
 658
 659def test_an_unknown_type_is_refused_at_the_open_line(
 660    tmp_path: Path, make_app: _MakeApp
 661) -> None:
 662    _blocks(
 663        tmp_path,
 664        {
 665            "docs/page_q.rst": "Q\n=\n\n.. src-trace::\n   :project: src\n   :file: q.c\n",
 666            "src/q.c": "// a block of an unknown type\n// @need nosuchtype: T\n"
 667            "// :id: X_1\n// @endneed\n",
 668        },
 669    )
 670    app = _build(tmp_path, make_app)
 671
 672    assert build_warnings(app) == [
 673        _multiline(
 674            "src/q.c:2",
 675            "invalid_type: multi-line need could not be created: Unknown need type "
 676            "'nosuchtype'.",
 677        )
 678    ]
 679    assert _need(app, "X_1") is None
 680
 681
 682DUPLICATES = """\
 683/**
 684 * @need req: Block first
 685 * :id: DUP_1
 686 * @endneed
 687 */
 688void a() {}
 689// @one-line after the block, DUP_1, impl
 690void b() {}
 691// @one-line before the block, DUP_2, impl
 692void c() {}
 693// @need req: Block after the one-line need
 694// :id: DUP_2
 695// @endneed
 696void d() {}
 697// @need req: The first of two blocks
 698// :id: DUP_3
 699// @endneed
 700void e() {}
 701// @need req: The second of two blocks
 702// :id: DUP_3
 703// @endneed
 704void f() {}
 705// @need req: Generated id block
 706// @endneed
 707void g() {}
 708"""
 709
 710
 711def _duplicate(location: str, kind: str, need_id: str) -> str:
 712    return _warning(
 713        location,
 714        f"{kind} {need_id!r} is already defined in document 'page_d': not created again "
 715        "by the src-trace directive in 'page_d' (give the markers distinct ids)",
 716        "duplicate_need",
 717    )
 718
 719
 720def test_one_need_per_id_across_kinds_the_earlier_line_wins(
 721    tmp_path: Path, make_app: _MakeApp
 722) -> None:
 723    """One-line needs and blocks are one list in source order: of two markers with one
 724    id, whatever their kinds, the earlier keeps it and the warning names the kind of the
 725    one skipped. A block without ``:id:`` gets the id Sphinx-Needs generates."""
 726    _blocks(
 727        tmp_path,
 728        {
 729            "docs/page_d.rst": "D\n=\n\n.. src-trace::\n   :project: src\n   :file: d.c\n",
 730            "src/d.c": DUPLICATES,
 731        },
 732    )
 733    app = _build(tmp_path, make_app)
 734
 735    assert build_warnings(app) == [
 736        _duplicate("src/d.c:7", "one-line need", "DUP_1"),
 737        _duplicate("src/d.c:11", "multi-line need", "DUP_2"),
 738        _duplicate("src/d.c:19", "multi-line need", "DUP_3"),
 739    ]
 740    needs = _json(app)["needs"]
 741    assert (needs["DUP_1"]["type"], needs["DUP_1"]["title"]) == ("req", "Block first")
 742    assert (needs["DUP_2"]["type"], needs["DUP_2"]["title"]) == (
 743        "impl",
 744        "one-line before the block",
 745    )
 746    assert needs["DUP_3"]["title"] == "The first of two blocks"
 747    generated = "R_" + hashlib.sha1(b"Generated id block").hexdigest().upper()[:5]
 748    assert needs[generated]["title"] == "Generated id block"
 749
 750
 751ORDER = {
 752    "src/order/a.c": (
 753        "// @one-line A1, IMPL_A1_ONE, impl\n"
 754        "void a1() {}\n"
 755        "// @need req: Block B1\n"
 756        "// :id: REQ_B1_BLK\n"
 757        "// @endneed\n"
 758        "void b1() {}\n\n\n\n\n"
 759        "// @one-line A2, IMPL_A2_ONE, impl\n"
 760        "void a2() {}\n"
 761    ),
 762    "src/order/b.c": (
 763        "void nothing() {}\n"
 764        "// @one-line C1, IMPL_C1_ONE, impl\n"
 765        "void c1() {}\n"
 766        "// @need req: Block D1\n"
 767        "// :id: REQ_D1_BLK\n"
 768        "// @endneed\n"
 769        "void d1() {}\n"
 770    ),
 771}
 772
 773
 774def _card_order(app: SphinxTestApp, page: str) -> list[str]:
 775    html = Path(app.outdir, page).read_text(encoding="utf-8")
 776    return list(
 777        dict.fromkeys(re.findall(r'<table class="need[^"]*" id="([A-Z0-9_]+)"', html))
 778    )
 779
 780
 781def test_cards_follow_the_open_lines_across_files(
 782    tmp_path: Path, make_app: _MakeApp
 783) -> None:
 784    """A block takes its place by its open line, in the order one-line needs already
 785    have: by row across the directive's files, then by file."""
 786    _blocks(
 787        tmp_path,
 788        {
 789            **ORDER,
 790            "docs/page_o.rst": "O\n=\n\n.. src-trace::\n   :project: src\n"
 791            "   :directory: order\n",
 792        },
 793    )
 794    app = _build(tmp_path, make_app)
 795
 796    assert build_warnings(app) == []
 797    assert _card_order(app, "page_o.html") == [
 798        "IMPL_A1_ONE",
 799        "IMPL_C1_ONE",
 800        "REQ_B1_BLK",
 801        "REQ_D1_BLK",
 802        "IMPL_A2_ONE",
 803    ]
 804
 805
 806@pytest.mark.skipif(
 807    not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
 808)
 809def test_parallel_build_matches_the_serial_one(
 810    tmp_path: Path, make_app: _MakeApp
 811) -> None:
 812    """``-j 2`` records the same needs, warnings and content as a serial build: a block
 813    is created through ``add_need``, which the parallel merge already handles."""
 814    _blocks(tmp_path, FOUR_CELLS)
 815    serial = _build(tmp_path, make_app)
 816    serial_needs = _json(serial)["needs"]
 817    serial_warnings = sorted(build_warnings(serial))
 818    serial_html = {
 819        need_id: _content_html(serial, f"{page}.html", need_id)
 820        for need_id, (_, page, _, _) in CELLS.items()
 821    }
 822
 823    parallel = _build(tmp_path, make_app, parallel=2)
 824
 825    assert _json(parallel)["needs"] == serial_needs
 826    assert sorted(_warnings(parallel)) == serial_warnings
 827    assert {
 828        need_id: _content_html(parallel, f"{page}.html", need_id)
 829        for need_id, (_, page, _, _) in CELLS.items()
 830    } == serial_html
 831
 832
 833INCREMENTAL = (
 834    "// @need req: Other title\n//\n// Other body.\n// @endneed\nvoid o() {}\n"
 835)
 836
 837
 838def test_an_edited_block_is_read_again(tmp_path: Path, make_app: _MakeApp) -> None:
 839    """An incremental build after editing a block's body updates its content; after
 840    editing its title the generated id moves, with no duplicate warning."""
 841    _blocks(
 842        tmp_path,
 843        {
 844            "docs/page_i.rst": "I\n=\n\n.. src-trace::\n   :project: src\n   :file: i.c\n",
 845            "src/i.c": INCREMENTAL,
 846        },
 847    )
 848    first_id = "R_" + hashlib.sha1(b"Other title").hexdigest().upper()[:5]
 849    app = _build(tmp_path, make_app)
 850    assert _need(app, first_id)["content"] == "Other body."  # type: ignore[index]
 851
 852    source = tmp_path / "src" / "i.c"
 853    source.write_text(
 854        INCREMENTAL.replace("Other body.", "Edited body."), encoding="utf-8"
 855    )
 856    _touch_later(source)
 857    app = _build(tmp_path, make_app, freshenv=False)
 858    assert _warnings(app) == []
 859    assert _need(app, first_id)["content"] == "Edited body."  # type: ignore[index]
 860
 861    source.write_text(
 862        INCREMENTAL.replace("Other body.", "Edited body.").replace(
 863            "Other title", "Edited title"
 864        ),
 865        encoding="utf-8",
 866    )
 867    _touch_later(source)
 868    app = _build(tmp_path, make_app, freshenv=False)
 869    second_id = "R_" + hashlib.sha1(b"Edited title").hexdigest().upper()[:5]
 870    assert _warnings(app) == []
 871    assert _need(app, first_id) is None
 872    assert _need(app, second_id)["content"] == "Edited body."  # type: ignore[index]
 873
 874
 875def test_analyser_warnings_on_blocks_are_multiline_need(
 876    tmp_path: Path, make_app: _MakeApp
 877) -> None:
 878    """A malformed block is reported at its source line under ``codelinks.multiline_need``
 879    -- every warning about blocks in the build shares one subtype -- while a malformed
 880    one-line marker stays ``codelinks.oneline``."""
 881    _blocks(
 882        tmp_path,
 883        {
 884            "docs/page_w.rst": "W\n=\n\n.. src-trace::\n   :project: src\n   :file: w.c\n",
 885            "src/w.c": "// @need req: Never closed\n// :id: REQ_OPEN\nvoid w() {}\n"
 886            "// @only a title, X_1, impl, [A], extra\nvoid x() {}\n",
 887        },
 888    )
 889    app = _build(tmp_path, make_app)
 890
 891    assert build_warnings(app) == [
 892        _multiline(
 893            "src/w.c:1",
 894            "multiline_need_unterminated: no '@endneed' line before the comment ends; "
 895            "the block is skipped",
 896        ),
 897        _warning(
 898            "src/w.c:4",
 899            "too_many_fields: 5 given fields, maximum is 4",
 900            "oneline",
 901        ),
 902    ]
 903
 904
 905TXT_MARKUP = """
 906[codelinks.projects.src.analyse.multiline_needs]
 907markups = { rst = ".rst", md = ".md", txt = ".txt" }
 908"""
 909
 910PAGE_K = """\
 911K
 912=
 913
 914.. src-trace::
 915   :project: src
 916   :file: k.c
 917
 918.. src-trace::
 919   :project: src
 920   :file: k2.c
 921
 922.. src-trace::
 923   :project: src
 924   :file: k3.c
 925"""
 926
 927
 928def _txt_block(need_id: str, *, hide: bool = False, body: str | None = None) -> str:
 929    """A ``@need[txt]`` block: ``.txt`` is a suffix no parser of the project claims."""
 930    lines = [f"// @need[txt] req: {need_id} title", f"// :id: {need_id}"]
 931    if hide:
 932        lines.append("// :hide:")
 933    if body is not None:
 934        lines += ["//", f"// {body}"]
 935    return "\n".join([*lines, "// @endneed", f"void {need_id.lower()}() {{}}", ""])
 936
 937
 938UNCLAIMED = {
 939    "docs/page_k.rst": PAGE_K,
 940    # two blocks with a body and a hidden one: one warning for the directive
 941    "src/k.c": _txt_block("TXT_1", body="Plain *one* text.")
 942    + _txt_block("TXT_2", body="Plain *two* text.")
 943    + _txt_block("TXT_HIDDEN", hide=True, body="Hidden *text*."),
 944    # a hidden block and one without a body: nothing is parsed, nothing warned
 945    "src/k2.c": _txt_block("TXT_HIDDEN_2", hide=True, body="Hidden *text*.")
 946    + _txt_block("TXT_BLANK"),
 947    # another directive: warned again
 948    "src/k3.c": _txt_block("TXT_3", body="Plain *three* text."),
 949}
 950
 951
 952def test_an_unclaimed_markup_is_parsed_as_the_page(
 953    tmp_path: Path, make_app: _MakeApp
 954) -> None:
 955    """A markup whose suffix no parser claims is parsed as the page's markup: every
 956    need kept, its ``doctype`` as declared, and one warning per directive and markup at
 957    the directive -- none where every such block is hidden or has no body."""
 958    _blocks(tmp_path, UNCLAIMED, toml_extra=TXT_MARKUP)
 959    app = _build(tmp_path, make_app)
 960
 961    unclaimed = (
 962        "Multi-line needs declare doctype '.txt', which this project cannot parse "
 963        "content in: '.txt' is not a registered source suffix (registered: '.md', "
 964        "'.rst'). Their content was parsed as this page's markup instead. Add the suffix "
 965        "to source_suffix with a reStructuredText or MyST parser, or map the markup to a "
 966        "suffix it parses."
 967    )
 968    assert build_warnings(app) == [
 969        _multiline("<srcdir>/page_k.rst:4", unclaimed),
 970        _multiline("<srcdir>/page_k.rst:12", unclaimed),
 971    ]
 972    needs = _json(app)["needs"]
 973    for need_id in (
 974        "TXT_1",
 975        "TXT_2",
 976        "TXT_HIDDEN",
 977        "TXT_HIDDEN_2",
 978        "TXT_BLANK",
 979        "TXT_3",
 980    ):
 981        assert needs[need_id]["doctype"] == ".txt", need_id
 982    for need_id, word in (("TXT_1", "one"), ("TXT_2", "two"), ("TXT_3", "three")):
 983        assert f"<em>{word}</em>" in _content_html(app, "page_k.html", need_id)
 984    assert _card(app, "page_k.html", "TXT_HIDDEN") is None
 985
 986
 987def test_a_hidden_block_with_a_declared_full_title_is_not_warned_unclaimed(
 988    tmp_path: Path, make_app: _MakeApp
 989) -> None:
 990    """A hidden block without ``:id:`` whose ``:full_title:`` the project declares, in a
 991    markup no parser claims: nothing of it is parsed, so nothing is warned -- the
 992    lookup that finds it hidden uses the id it is given (#2410)."""
 993    _blocks(
 994        tmp_path,
 995        {
 996            "docs/conf.py": CONF.replace(
 997                "needs_fields = {", 'needs_fields = {"full_title": {"nullable": True}, '
 998            ),
 999            "docs/page_h.rst": "H\n=\n\n.. src-trace::\n   :project: src\n   :file: h.c\n",
1000            "src/h.c": "// @need[txt] req: Short title\n"
1001            "// :full_title: A much longer title\n// :hide:\n//\n// Hidden *text*.\n"
1002            "// @endneed\nvoid h() {}\n",
1003        },
1004        toml_extra=TXT_MARKUP,
1005    )
1006    app = _build(tmp_path, make_app)
1007    assert _warnings(app) == []
1008    assert _need(app, SHORT_TITLE_ID) is not None
1009    assert _card(app, "page_h.html", SHORT_TITLE_ID) is None
1010
1011
1012TEMPLATE = "**TPL bold** and `tlink <https://t.example>`_ then: {{content}}\n"
1013TEMPLATED_BLOCK = """\
1014// @need[md] req: Templated block
1015// :id: REQ_TPL
1016// {option}
1017//
1018// Body [blink](https://b.example).
1019// @endneed
1020void templated() {{}}
1021"""
1022
1023
1024@pytest.mark.parametrize(
1025    ("option", "conf"),
1026    [
1027        pytest.param(":template: rsttpl", "", id="the_blocks_template"),
1028        pytest.param(
1029            ":status: open",
1030            'needs_fields = {"template": {"default": "rsttpl"}}\n',
1031            id="the_projects_default",
1032        ),
1033        pytest.param(
1034            ":status: open",
1035            'needs_fields = {"template": {"predicates": '
1036            '[(\'id == "REQ_TPL"\', "rsttpl")]}}\n',
1037            id="a_project_predicate",
1038        ),
1039    ],
1040)
1041def test_a_templated_block_is_parsed_as_the_page(
1042    tmp_path: Path, make_app: _MakeApp, option: str, conf: str
1043) -> None:
1044    """A template is a file of the project, written in the page's markup: a block
1045    rendered through one -- its own ``:template:``, or one the project gives -- is
1046    parsed as the page's markup, template and body together, as ``needimport`` does."""
1047    _blocks(
1048        tmp_path,
1049        {
1050            "docs/conf.py": CONF.replace("needs_fields = ", "_unused = ") + conf,
1051            "docs/needs_templates/rsttpl.need": TEMPLATE,
1052            "docs/page_t.rst": "T\n=\n\n.. src-trace::\n   :project: src\n   :file: t.c\n",
1053            "src/t.c": TEMPLATED_BLOCK.format(option=option),
1054        },
1055    )
1056    app = _build(tmp_path, make_app)
1057
1058    assert build_warnings(app) == []
1059    html = _content_html(app, "page_t.html", "REQ_TPL")
1060    # the template's reStructuredText renders ...
1061    assert "<strong>TPL bold</strong>" in html
1062    assert '<a class="reference external" href="https://t.example">tlink</a>' in html
1063    # ... and so the body is reStructuredText too: its Markdown link is text
1064    assert "[blink](" in html
1065    assert _need(app, "REQ_TPL")["doctype"] == ".md"  # type: ignore[index]
1066
1067
1068def test_an_upper_case_id_is_the_id(tmp_path: Path, make_app: _MakeApp) -> None:
1069    """docutils lowercases a directive's option names, so ``:ID:`` is the id."""
1070    _blocks(
1071        tmp_path,
1072        {
1073            "docs/page_q.rst": "Q\n=\n\n.. src-trace::\n   :project: src\n   :file: q.c\n",
1074            "src/q.c": Q3_HEAD + "// :ID: REQ_UPPER\n" + Q3_BODY,
1075        },
1076    )
1077    app = _build(tmp_path, make_app)
1078
1079    assert build_warnings(app) == []
1080    assert _need(app, "REQ_UPPER") is not None
1081
1082
1083def test_a_fallback_body_still_names_the_source_line(
1084    tmp_path: Path, make_app: _MakeApp
1085) -> None:
1086    """A body in a markup no parser claims is parsed in the page's markup, still
1087    anchored at its source line: a bad role in it names the source file and line."""
1088    _blocks(
1089        tmp_path,
1090        {
1091            "docs/page_f.rst": "F\n=\n\n.. src-trace::\n   :project: src\n   :file: fb.c\n",
1092            "src/fb.c": "// @need[txt] req: Fallback body\n// :id: REQ_FB\n//\n"
1093            "// A bad role :nosuchrole:`x` here.\n// @endneed\nvoid fb() {}\n",
1094        },
1095        toml_extra=TXT_MARKUP,
1096    )
1097    app = _build(tmp_path, make_app)
1098
1099    docutils = " [docutils]" if _SHOWS_WARNING_TYPES else ""
1100    assert build_warnings(app, srcdir=tmp_path) == [
1101        f"<srcdir>/{_at('src', 'fb.c')}:4: ERROR: Unknown interpreted text role "
1102        f'"nosuchrole".{docutils}',
1103        _multiline(
1104            f"<srcdir>/{_at('docs', 'page_f.rst')}:4",
1105            "Multi-line needs declare doctype '.txt', which this project cannot parse "
1106            "content in: '.txt' is not a registered source suffix (registered: '.md', "
1107            "'.rst'). Their content was parsed as this page's markup instead. Add the "
1108            "suffix to source_suffix with a reStructuredText or MyST parser, or map the "
1109            "markup to a suffix it parses.",
1110        ),
1111    ]
1112    assert _need(app, "REQ_FB")["doctype"] == ".txt"  # type: ignore[index]
1113
1114
1115def test_full_title_does_not_move_the_predicted_id(
1116    tmp_path: Path, make_app: _MakeApp
1117) -> None:
1118    """A block without ``:id:`` but with ``:full_title:`` (not an option it takes, so
1119    ignored): the id predicted for the duplicate check and the ``[docs]`` link is the
1120    one the need gets, from the title alone."""
1121    _blocks(
1122        tmp_path,
1123        {
1124            "docs/page_f.rst": "F\n=\n\n.. src-trace::\n   :project: src\n   :file: f.c\n",
1125            "src/f.c": "// @need req: Short title\n// :full_title: A much longer title\n"
1126            "// @endneed\nvoid f() {}\n",
1127        },
1128    )
1129    app = _build(tmp_path, make_app)
1130    need_id = "R_" + hashlib.sha1(b"Short title").hexdigest().upper()[:5]
1131    assert need_id in _json(app)["needs"]
1132    page = Path(app.outdir, "src", "f.html").read_text(encoding="utf-8")
1133    assert f'href="../page_f.html#{need_id}">[docs]</a>' in page
1134
1135
1136#: the id of a block titled ``Short title``, hashed from its title
1137SHORT_TITLE_ID = "R_" + hashlib.sha1(b"Short title").hexdigest().upper()[:5]
1138#: the id hashed from its ``full_title``, which a block's need is never given (#2410)
1139FULL_TITLE_ID = "R_" + hashlib.sha1(b"A much longer title").hexdigest().upper()[:5]
1140
1141
1142def _declared_full_title(
1143    tmp_path: Path, make_app: _MakeApp, owner: str | None = None
1144) -> SphinxTestApp:
1145    """Build a block without ``:id:`` whose ``:full_title:`` the project declares as a
1146    field, on ``page_f``; with ``owner``, a need of that id is written in ``owner.rst``,
1147    read before ``page_f``."""
1148    files = {
1149        "docs/conf.py": CONF.replace(
1150            "needs_fields = {", 'needs_fields = {"full_title": {"nullable": True}, '
1151        ),
1152        "docs/page_f.rst": "F\n=\n\n.. src-trace::\n   :project: src\n   :file: f.c\n",
1153        "src/f.c": "// @need req: Short title\n// :full_title: A much longer title\n"
1154        "// @endneed\nvoid f() {}\n",
1155    }
1156    if owner is not None:
1157        files["docs/owner.rst"] = (
1158            f"Owner\n=====\n\n.. req:: Written by hand\n   :id: {owner}\n"
1159        )
1160    _blocks(tmp_path, files)
1161    return _build(tmp_path, make_app)
1162
1163
1164def test_a_declared_full_title_is_the_field_and_the_id_is_the_title_s(
1165    tmp_path: Path, make_app: _MakeApp
1166) -> None:
1167    """A block without ``:id:`` whose ``:full_title:`` the project declares: the field
1168    holds it, the need's id is hashed from the title (the record path passes no full
1169    title), and the id the duplicate check and the ``[docs]`` link use -- the one
1170    :func:`would_be_id` predicts -- is that same id (#2410)."""
1171    app = _declared_full_title(tmp_path, make_app)
1172    needs = _json(app)["needs"]
1173    on_page = [
1174        need_id for need_id, need in needs.items() if need["docname"] == "page_f"
1175    ]
1176    assert on_page == [SHORT_TITLE_ID]
1177    assert needs[SHORT_TITLE_ID]["full_title"] == "A much longer title"
1178    page = Path(app.outdir, "src", "f.html").read_text(encoding="utf-8")
1179    assert re.findall(r'href="([^"]*)">\[docs\]</a>', page) == [
1180        f"../page_f.html#{SHORT_TITLE_ID}"
1181    ]
1182
1183
1184def test_a_declared_full_title_s_duplicate_check_names_the_need_s_id(
1185    tmp_path: Path, make_app: _MakeApp
1186) -> None:
1187    """The duplicate check looks up the id the block's need would get: a need that owns
1188    it already is found, and the block is skipped with the duplicate warning, naming
1189    that id, rather than refused by Sphinx-Needs (#2410)."""
1190    app = _declared_full_title(tmp_path, make_app, owner=SHORT_TITLE_ID)
1191    assert _warnings(app) == [
1192        _warning(
1193            "src/f.c:1",
1194            f"multi-line need {SHORT_TITLE_ID!r} is already defined in document "
1195            "'owner': not created again by the src-trace directive in 'page_f' "
1196            "(narrow one directive's scope)",
1197            "duplicate_need",
1198        )
1199    ]
1200    assert _need(app, SHORT_TITLE_ID)["title"] == "Written by hand"  # type: ignore[index]
1201
1202
1203def test_a_declared_full_title_s_skip_is_read_again_when_its_owner_goes(
1204    tmp_path: Path, make_app: _MakeApp
1205) -> None:
1206    """The skip is remembered as any duplicate's is: when the need that owned the id
1207    goes, the next incremental build reads ``page_f`` again and the block's need is
1208    created (#2410)."""
1209    _declared_full_title(tmp_path, make_app, owner=SHORT_TITLE_ID)
1210    owner = tmp_path / "docs" / "owner.rst"
1211    owner.write_text("Owner\n=====\n\nNo need.\n", encoding="utf-8")
1212    _touch_later(owner)
1213    app = _build(tmp_path, make_app, freshenv=False)
1214    assert _warnings(app) == []
1215    assert _need(app, SHORT_TITLE_ID)["docname"] == "page_f"  # type: ignore[index]
1216
1217
1218def test_a_need_of_the_full_title_s_id_does_not_skip_the_block(
1219    tmp_path: Path, make_app: _MakeApp
1220) -> None:
1221    """A need whose id is the one hashed from the block's ``:full_title:`` is no
1222    duplicate: the block's need is not given that id, so it is created (#2410)."""
1223    app = _declared_full_title(tmp_path, make_app, owner=FULL_TITLE_ID)
1224    assert _warnings(app) == []
1225    needs = _json(app)["needs"]
1226    assert needs[FULL_TITLE_ID]["title"] == "Written by hand"
1227    assert needs[SHORT_TITLE_ID]["title"] == "Short title"
1228
1229
1230def test_a_link_type_named_full_title_is_kept_on_a_block(
1231    tmp_path: Path, make_app: _MakeApp
1232) -> None:
1233    """A block without ``:id:`` that sets a LINK type named ``full_title``: the need
1234    keeps the link -- the record path dropped it as the legacy ``full_title`` key
1235    (#2466) -- and its id is hashed from the title, the one the ``[docs]`` link names."""
1236    _blocks(
1237        tmp_path,
1238        {
1239            "docs/conf.py": CONF + 'needs_links = {"full_title": {}}\n',
1240            "docs/page_f.rst": "F\n=\n\n.. src-trace::\n   :project: src\n   :file: f.c\n",
1241            "src/f.c": "// @need req: Short title\n// :full_title: REQ_HOST\n"
1242            "// @endneed\nvoid f() {}\n",
1243        },
1244    )
1245    app = _build(tmp_path, make_app)
1246    assert _warnings(app) == []
1247    assert _need(app, SHORT_TITLE_ID)["full_title"] == ["REQ_HOST"]  # type: ignore[index]
1248    page = Path(app.outdir, "src", "f.html").read_text(encoding="utf-8")
1249    assert re.findall(r'href="([^"]*)">\[docs\]</a>', page) == [
1250        f"../page_f.html#{SHORT_TITLE_ID}"
1251    ]
1252
1253
1254#: a project declaring a field named ``description``
1255DESCRIPTION_CONF = CONF.replace(
1256    "needs_fields = {", 'needs_fields = {"description": {"nullable": True}, '
1257)
1258#: the id of a block with an empty title and no body: hashed from the empty content
1259EMPTY_TITLE_ID = "R_" + hashlib.sha1(b"").hexdigest().upper()[:5]
1260
1261
1262def test_a_declared_description_is_kept_on_a_body_less_block(
1263    tmp_path: Path, make_app: _MakeApp
1264) -> None:
1265    """A block with no body whose ``:description:`` the project declares: the field
1266    holds it and the content stays empty -- the legacy rename (sphinx-needs before 4.0
1267    wrote the content as ``description``) is for a record with no ``content``, and a
1268    block's record always has one (#2465)."""
1269    _blocks(
1270        tmp_path,
1271        {
1272            "docs/conf.py": DESCRIPTION_CONF,
1273            "docs/page_d.rst": "D\n=\n\n.. src-trace::\n   :project: src\n   :file: d.c\n",
1274            "src/d.c": "// @need req: Block need\n// :id: REQ_BLK\n"
1275            "// :description: block text\n// @endneed\nvoid f() {}\n",
1276        },
1277    )
1278    app = _build(tmp_path, make_app)
1279    assert _warnings(app) == []
1280    need = _need(app, "REQ_BLK")
1281    assert need is not None
1282    assert (need["description"], need["content"]) == ("block text", "")
1283
1284
1285def test_a_declared_description_does_not_move_the_predicted_id(
1286    tmp_path: Path, make_app: _MakeApp
1287) -> None:
1288    """A block without ``:id:``, with an empty title and no body, whose
1289    ``:description:`` the project declares: its id is hashed from the empty content,
1290    the one :func:`would_be_id` predicts for the duplicate check and the ``[docs]`` link
1291    -- not from the field's text taken as the content (#2465)."""
1292    _blocks(
1293        tmp_path,
1294        {
1295            "docs/conf.py": DESCRIPTION_CONF,
1296            "docs/page_d.rst": "D\n=\n\n.. src-trace::\n   :project: src\n   :file: d.c\n",
1297            "src/d.c": "// @need req:\n// :description: hashed from me\n"
1298            "// @endneed\nvoid f() {}\n",
1299        },
1300    )
1301    app = _build(tmp_path, make_app)
1302    needs = _json(app)["needs"]
1303    on_page = [
1304        need_id for need_id, need in needs.items() if need["docname"] == "page_d"
1305    ]
1306    assert on_page == [EMPTY_TITLE_ID]
1307    assert needs[EMPTY_TITLE_ID]["description"] == "hashed from me"
1308    page = Path(app.outdir, "src", "d.html").read_text(encoding="utf-8")
1309    assert re.findall(r'href="([^"]*)">\[docs\]</a>', page) == [
1310        f"../page_d.html#{EMPTY_TITLE_ID}"
1311    ]
1312
1313
1314def test_a_link_type_named_description_is_kept_on_a_block(
1315    tmp_path: Path, make_app: _MakeApp
1316) -> None:
1317    """A body-less block that sets a LINK type named ``description``: the need keeps
1318    the link, where the legacy rename made its text the content and lost it (#2465)."""
1319    _blocks(
1320        tmp_path,
1321        {
1322            "docs/conf.py": CONF + 'needs_links = {"description": {}}\n',
1323            "docs/page_d.rst": "D\n=\n\n.. src-trace::\n   :project: src\n   :file: d.c\n",
1324            "src/d.c": "// @need req: Block need\n// :id: REQ_BLK\n"
1325            "// :description: REQ_HOST\n// @endneed\nvoid f() {}\n",
1326        },
1327    )
1328    app = _build(tmp_path, make_app)
1329    assert _warnings(app) == []
1330    need = _need(app, "REQ_BLK")
1331    assert need is not None
1332    assert (need["description"], need["content"]) == (["REQ_HOST"], "")
1333
1334
1335def test_one_line_needs_keep_the_analysis_order(
1336    tmp_path: Path, make_app: _MakeApp
1337) -> None:
1338    """Two files whose string order and path order differ (``a-c.c`` before ``a/b.c``
1339    as strings, after as paths), a one-line need on the same row in each: the cards
1340    keep the order the analysis gives, the one before multi-line needs."""
1341    _blocks(
1342        tmp_path,
1343        {
1344            "src/o2/a/b.c": "// @one-line in b, IMPL_AB_ONE, impl\n",
1345            "src/o2/a-c.c": "// @one-line in ac, IMPL_AC_ONE, impl\n",
1346            "docs/page_o2.rst": "O\n=\n\n.. src-trace::\n   :project: src\n"
1347            "   :directory: o2\n",
1348        },
1349    )
1350    app = _build(tmp_path, make_app)
1351    assert _card_order(app, "page_o2.html") == ["IMPL_AC_ONE", "IMPL_AB_ONE"]
1352
1353
1354XPARSER = """\
1355from docutils.parsers.rst import Parser as _Rst
1356from sphinx.parsers import Parser as _SphinxParser
1357
1358
1359class XParser(_SphinxParser):
1360    supported = ("xrst",)
1361
1362    def parse(self, inputstring, document):
1363        _Rst().parse(inputstring, document)
1364
1365
1366def setup(app):
1367    app.add_source_suffix(".xrst", "xrst")
1368    app.add_source_parser(XParser)
1369"""
1370
1371
1372def test_a_fallback_on_a_page_of_a_third_parser_keeps_the_need(
1373    tmp_path: Path, make_app: _MakeApp
1374) -> None:
1375    """On a page whose own parser is neither reStructuredText nor MyST, the page's
1376    suffix is unclaimed too: the body is parsed by the page's parser, unanchored (its
1377    warnings at the page), and the need is kept."""
1378    _blocks(
1379        tmp_path,
1380        {
1381            "docs/xparser_ext.py": XPARSER,
1382            "docs/conf.py": CONF
1383            + "import os, sys\nsys.path.insert(0, os.path.dirname(__file__))\n"
1384            "extensions.append('xparser_ext')\n"
1385            'source_suffix = {".rst": "restructuredtext", ".md": "markdown", '
1386            '".xrst": "xrst"}\n',
1387            "docs/page_x.xrst": "X\n=\n\n.. src-trace::\n   :project: src\n   :file: fbx.c\n",
1388            "src/fbx.c": "// @need[txt] req: Third parser\n// :id: REQ_FBX\n//\n"
1389            "// A bad role :nosuchrole:`x` and *em*.\n// @endneed\nvoid f() {}\n",
1390        },
1391        toml_extra=TXT_MARKUP,
1392    )
1393    app = _build(tmp_path, make_app)
1394
1395    docutils = " [docutils]" if _SHOWS_WARNING_TYPES else ""
1396    assert build_warnings(app) == [
1397        f'<srcdir>/page_x.xrst:4: ERROR: Unknown interpreted text role "nosuchrole".'
1398        f"{docutils}",
1399        _multiline(
1400            "<srcdir>/page_x.xrst:4",
1401            "Multi-line needs declare doctype '.txt', which this project cannot parse "
1402            "content in: '.txt' is not a registered source suffix (registered: '.md', "
1403            "'.rst', '.xrst'). Their content was parsed as this page's markup instead. "
1404            "Add the suffix to source_suffix with a reStructuredText or MyST parser, or "
1405            "map the markup to a suffix it parses.",
1406        ),
1407    ]
1408    assert _need(app, "REQ_FBX")["doctype"] == ".txt"  # type: ignore[index]
1409    assert "<em>em</em>" in _content_html(app, "page_x.html", "REQ_FBX")
1410
1411
1412def test_a_fallback_on_a_myst_page_is_myst(tmp_path: Path, make_app: _MakeApp) -> None:
1413    """On a MyST page an unclaimed markup falls back to MyST, the page's markup, still
1414    anchored at the body's source line: its Markdown link renders, and an unresolved
1415    ``{ref}`` names the source file and line."""
1416    _blocks(
1417        tmp_path,
1418        {
1419            "docs/page_m.md": "# M\n\n```{src-trace}\n:project: src\n:file: fbm.c\n```\n",
1420            "src/fbm.c": "// @need[txt] req: MyST fallback\n// :id: REQ_FBM\n//\n"
1421            "// A [link](https://x.example) and {ref}`nolabel_md`.\n// @endneed\n"
1422            "void f() {}\n",
1423        },
1424        toml_extra=TXT_MARKUP,
1425    )
1426    app = _build(tmp_path, make_app)
1427
1428    ref = " [ref.ref]" if _SHOWS_WARNING_TYPES else ""
1429    assert sorted(build_warnings(app, srcdir=tmp_path)) == sorted(
1430        [
1431            f"<srcdir>/{_at('src', 'fbm.c')}:4: WARNING: undefined label: "
1432            f"'nolabel_md'{ref}",
1433            _multiline(
1434                f"<srcdir>/{_at('docs', 'page_m.md')}:3",
1435                "Multi-line needs declare doctype '.txt', which this project cannot "
1436                "parse content in: '.txt' is not a registered source suffix "
1437                "(registered: '.md', '.rst'). Their content was parsed as this page's "
1438                "markup instead. Add the suffix to source_suffix with a "
1439                "reStructuredText or MyST parser, or map the markup to a suffix it "
1440                "parses.",
1441            ),
1442        ]
1443    )
1444    assert _need(app, "REQ_FBM")["doctype"] == ".txt"  # type: ignore[index]
1445    assert '<a class="reference external" href="https://x.example">link</a>' in (
1446        _content_html(app, "page_m.html", "REQ_FBM")
1447    )