[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 )