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