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