[docs] 1# @Test suite for one one-line need per id when src-trace scopes overlap, TEST_ONE_NEED_PER_ID_1, test, [IMPL_LNK_1]
2"""A one-line need two ``src-trace`` directives both find is created once (#2042).
3
4Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) with
5``src/impl.cpp`` holding the one-line need ``IMPL_1``, and ``index``'s directive narrowed
6to ``:file: refs.cpp`` unless a case says otherwise, so that only the directives a case
7adds find ``IMPL_1``. The first owner keeps an id -- the directive read first, a
8hand-written need, whatever defined it -- and every other directive finding it skips it
9with one ``codelinks.duplicate_need`` warning at the marker's line. A document that
10skipped a need is read again when the owner's document changes or goes, so the need
11moves instead of vanishing.
12"""
13
14import hashlib
15import re
16from pathlib import Path
17from typing import Any
18
19import pytest
20from sphinx.testing.util import SphinxTestApp
21from sphinx.util.console import strip_colors
22from sphinx.util.parallel import parallel_available
23
24from sphinx_codelinks.analyse import utils
25from sphinx_needs_testkit import build_warnings
26
27from .test_need_id_refs import (
28 _SHOWS_WARNING_TYPES,
29 DANGLING,
30 _build,
31 _json,
32 _MakeApp,
33 _project,
34)
35from .test_rediscovery import _scoped, _status, _touch_later
36from .test_url_links import _card_links as _field_links
37
38IMPL = "// @first impl, IMPL_1, impl, [REQ_001]\nvoid impl() {}\n"
39#: the files every case starts from: index traces refs.cpp only
40FILES = {
41 **_scoped(":file: refs.cpp"),
42 "src/impl.cpp": IMPL,
43 "src/other.cpp": "// nothing to trace\n",
44}
45
46
47def _trace(option: str = ":file: impl.cpp", project: str = "src") -> str:
48 return f"\n.. src-trace::\n :project: {project}\n {option}\n"
49
50
51def _duplicate(
52 owner: str,
53 docname: str,
54 location: str = "src/impl.cpp:1",
55 need_id: str = "IMPL_1",
56) -> str:
57 suffix = " [codelinks.duplicate_need]" if _SHOWS_WARNING_TYPES else ""
58 cure = (
59 "give the markers distinct ids"
60 if owner == docname
61 else "narrow one directive's scope"
62 )
63 return (
64 f"{location}: WARNING: one-line need {need_id!r} is already defined in document "
65 f"{owner!r}: not created again by the src-trace directive in {docname!r} "
66 f"({cure}){suffix}"
67 )
68
69
70def _duplicates(app: SphinxTestApp) -> list[str]:
71 return [w for w in build_warnings(app) if "is already defined" in w]
72
73
74def _owner(app: SphinxTestApp, need_id: str = "IMPL_1") -> str | None:
75 """The document ``need_id`` is defined in, per ``needs.json`` (``None``: no need)."""
76 need = _json(app)["needs"].get(need_id)
77 return None if need is None else need["docname"]
78
79
80def _cards(app: SphinxTestApp, page: str) -> int:
81 html = Path(app.outdir, page).read_text(encoding="utf-8")
82 return len(re.findall(r'id="IMPL_1"', html))
83
84
85def _rewrite(path: Path, old: str, new: str) -> None:
86 text = path.read_text(encoding="utf-8")
87 assert old in text
88 path.write_text(text.replace(old, new), encoding="utf-8")
89 _touch_later(path)
90
91
92#: a one-line style without ``id``: Sphinx-Needs generates it from the type's prefix and
93#: the title (local URLs off here; on, in the #2082 case)
94IDLESS = {
95 "toml_extra": (
96 "\n[codelinks.projects.src.analyse.oneline_comment_style]\n"
97 'needs_fields = [{ name = "title" }, { name = "type", default = "impl" }]\n'
98 ),
99 "toml_replace": ("set_local_url = true", "set_local_url = false"),
100}
101IDLESS_FILES = {**FILES, "src/impl.cpp": "// @first impl\nvoid impl() {}\n"}
102#: what Sphinx-Needs generates for ``first impl``: the ``impl`` type's prefix (``I_``) and
103#: the first five characters of the title's SHA-1 (``needs_id_length``), upper case
104GENERATED = "I_" + hashlib.sha1(b"first impl").hexdigest().upper()[:5]
105
106
107def test_two_directives_in_one_document_define_the_need_once(
108 tmp_path: Path, make_app: _MakeApp
109) -> None:
110 """``index`` traces the whole project and then ``impl.cpp`` again: one need, one
111 warning at the marker's line, and the build goes on (it used to abort with
112 ``duplicate_id``)."""
113 _project(
114 tmp_path,
115 files={**FILES, **_scoped(None)},
116 append={"docs/index.rst": _trace()},
117 )
118 app = _build(tmp_path, make_app)
119
120 assert _owner(app) == "index"
121 assert _duplicates(app) == [_duplicate("index", "index")]
122 assert _cards(app, "index.html") == 1
123
124
125def test_two_documents_the_earlier_sorting_one_defines_it(
126 tmp_path: Path, make_app: _MakeApp
127) -> None:
128 """Sphinx reads in sorted order: ``page1`` defines ``IMPL_1``, ``page2`` skips it."""
129 _project(
130 tmp_path,
131 files=FILES,
132 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
133 )
134 app = _build(tmp_path, make_app)
135
136 assert _owner(app) == "page1"
137 assert _duplicates(app) == [_duplicate("page1", "page2")]
138 # read time, before the attach's warning
139 assert build_warnings(app) == [_duplicate("page1", "page2"), DANGLING]
140 assert (_cards(app, "page1.html"), _cards(app, "page2.html")) == (1, 0)
141
142
143def test_the_need_moves_when_its_owner_is_removed(
144 tmp_path: Path, make_app: _MakeApp
145) -> None:
146 """``page2`` skipped ``IMPL_1``; once ``page1`` is gone, ``page2`` is read again
147 and defines it."""
148 _project(
149 tmp_path,
150 files=FILES,
151 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
152 )
153 assert _owner(_build(tmp_path, make_app)) == "page1"
154
155 (tmp_path / "docs" / "page1.rst").unlink()
156 app = _build(tmp_path, make_app, freshenv=False)
157
158 assert "0 added, 2 changed, 1 removed" in _status(app) # index: its glob toctree
159 assert re.search(r"reading sources\.\.\. \[[^\]]*\] page2\b", _status(app))
160 assert _owner(app) == "page2"
161 assert _duplicates(app) == []
162
163
164def test_the_need_moves_when_its_owner_no_longer_traces_it(
165 tmp_path: Path, make_app: _MakeApp
166) -> None:
167 """``page1``'s directive is narrowed to ``other.cpp``: ``page2`` is read again in
168 the same build and defines ``IMPL_1``."""
169 _project(
170 tmp_path,
171 files=FILES,
172 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
173 )
174 _build(tmp_path, make_app)
175
176 _rewrite(tmp_path / "docs" / "page1.rst", ":file: impl.cpp", ":file: other.cpp")
177 app = _build(tmp_path, make_app, freshenv=False)
178
179 assert "0 added, 2 changed, 0 removed" in _status(app)
180 assert _owner(app) == "page2"
181 assert _duplicates(app) == []
182
183
184def test_the_need_moves_when_its_owner_loses_the_file_to_an_ignore_rule(
185 tmp_path: Path, make_app: _MakeApp
186) -> None:
187 """A ``.gitignore`` takes ``impl.cpp`` out of ``page1``'s directory scope: ``page1``
188 is read again because its scope changed (no dependency of it changed), and so is
189 ``page2``, which skipped the need."""
190 _project(
191 tmp_path,
192 files=FILES,
193 append={
194 "docs/page1.rst": _trace(":directory: ."),
195 "docs/page2.rst": _trace(),
196 },
197 )
198 assert _owner(_build(tmp_path, make_app)) == "page1"
199
200 (tmp_path / "src" / ".gitignore").write_text("impl.cpp\n", encoding="utf-8")
201 app = _build(tmp_path, make_app, freshenv=False)
202
203 assert "0 added, 2 changed, 0 removed" in _status(app)
204 assert _owner(app) == "page2"
205 assert _duplicates(app) == []
206
207
208def test_the_earlier_sorting_directive_takes_the_need_when_both_are_read(
209 tmp_path: Path, make_app: _MakeApp
210) -> None:
211 """An incremental build keeps the incumbent: ``page2`` defined ``IMPL_1`` before
212 ``page1`` traced it, and ``page1``, read alone, defers. Once both are read in one
213 build, the earlier-sorting ``page1`` defines it -- what a fresh build gives -- since
214 ``page2``'s old need is about to be purged."""
215 _project(tmp_path, files=FILES, append={"docs/page2.rst": _trace()})
216 assert _owner(_build(tmp_path, make_app)) == "page2"
217
218 page1 = tmp_path / "docs" / "page1.rst"
219 page1.write_text(page1.read_text(encoding="utf-8") + _trace(), encoding="utf-8")
220 _touch_later(page1)
221 incumbent = _build(tmp_path, make_app, freshenv=False)
222 assert "0 added, 1 changed, 0 removed" in _status(incumbent)
223 assert _owner(incumbent) == "page2"
224 assert _duplicates(incumbent) == [_duplicate("page2", "page1")]
225
226 for page in ("page1", "page2"):
227 path = tmp_path / "docs" / f"{page}.rst"
228 path.write_text(path.read_text(encoding="utf-8") + "\n", encoding="utf-8")
229 _touch_later(path)
230 app = _build(tmp_path, make_app, freshenv=False)
231
232 assert "0 added, 2 changed, 0 removed" in _status(app)
233 assert _owner(app) == "page1"
234 assert _duplicates(app) == [_duplicate("page1", "page2")]
235
236
237def test_a_hand_written_need_keeps_its_id(tmp_path: Path, make_app: _MakeApp) -> None:
238 """Whatever defined the id first owns it -- here an rst need in ``page1``; the
239 directive in ``page2`` skips the marker (it used to abort with ``duplicate_id``)."""
240 _project(
241 tmp_path,
242 files=FILES,
243 append={
244 "docs/page1.rst": "\n.. impl:: Hand-written\n :id: IMPL_1\n",
245 "docs/page2.rst": _trace(),
246 },
247 )
248 app = _build(tmp_path, make_app)
249
250 assert _owner(app) == "page1"
251 assert _json(app)["needs"]["IMPL_1"]["title"] == "Hand-written"
252 assert _duplicates(app) == [_duplicate("page1", "page2")]
253
254
255def test_an_unchanged_owner_rereads_nothing(tmp_path: Path, make_app: _MakeApp) -> None:
256 """The skipping document is read again only when its owner changes or goes."""
257 _project(
258 tmp_path,
259 files=FILES,
260 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
261 )
262 _build(tmp_path, make_app)
263 app = _build(tmp_path, make_app, freshenv=False)
264
265 assert "0 added, 0 changed, 0 removed" in _status(app)
266 assert _owner(app) == "page1"
267
268
269def test_nested_projects_define_the_need_once(
270 tmp_path: Path, make_app: _MakeApp
271) -> None:
272 """Ids are global: ``src`` and ``sub`` (``src/sub``) both find ``sub/x.cpp``; the
273 project read first defines the need, the other skips it -- no configuration
274 error."""
275 _project(
276 tmp_path,
277 files={**_scoped(None), "src/sub/x.cpp": IMPL},
278 append={"docs/later.rst": _trace("", project="sub")},
279 toml_extra=(
280 "\n[codelinks.projects.sub]\n"
281 'remote_url_pattern = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"\n'
282 "\n[codelinks.projects.sub.source_discover]\n"
283 'src_dir = "../src/sub"\n'
284 'comment_type = "cpp"\n'
285 ),
286 )
287 app = _build(tmp_path, make_app)
288
289 assert _owner(app) == "index"
290 assert _duplicates(app) == [_duplicate("index", "later", "src/sub/x.cpp:1")]
291
292
293def test_the_summary_line_counts_the_skipped_needs(
294 tmp_path: Path, make_app: _MakeApp
295) -> None:
296 _project(
297 tmp_path,
298 files=FILES,
299 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
300 )
301 app = _build(tmp_path, make_app)
302
303 lines = re.findall(r"codelinks \[src\]: [^\n]*", _status(app))
304 # one line per directive (index, page1, page2) and the attach's; only page2 skipped
305 assert len(lines) == 4
306 assert [line for line in lines if "skipped" in line] == [
307 "codelinks [src]: 1 file, 1 marker, 1 skipped (already defined)"
308 ]
309
310
311@pytest.mark.skipif(
312 not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
313)
314def test_parallel_directives_in_two_chunks_leave_one_need(
315 tmp_path: Path, make_app: _MakeApp
316) -> None:
317 """``-j 2`` reads ``index .. page2`` and ``page3 .. sub/deep`` in two workers, which
318 cannot see each other's needs: both directives define ``IMPL_1``, and Sphinx-Needs'
319 merge keeps one and warns ``needs.duplicate_id`` -- accepted, and documented."""
320 _project(
321 tmp_path,
322 files=FILES,
323 append={"docs/page1.rst": _trace(), "docs/page3.rst": _trace()},
324 )
325 app = _build(tmp_path, make_app, parallel=2)
326
327 status = strip_colors(app._status.getvalue())
328 assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", status)
329 assert _owner(app) in {"page1", "page3"}
330 merged = [w for w in build_warnings(app) if "IMPL_1 already exists" in w]
331 assert len(merged) == 1, build_warnings(app)
332 if _SHOWS_WARNING_TYPES:
333 assert merged[0].endswith("[needs.duplicate_id]")
334 assert _duplicates(app) == []
335
336
337def test_a_scope_record_pickled_before_deferred_existed_still_loads() -> None:
338 """An environment pickled by an earlier release holds ``ScopeRecord`` objects
339 without the ``deferred`` field; they load, and defer nothing."""
340 import pickle
341
342 from sphinx_codelinks.sphinx_extension.rediscovery import ScopeRecord
343
344 record = ScopeRecord(project="src", kind="file", target="a.cpp", fingerprint=())
345 object.__delattr__(record, "deferred") # the state an older class pickled
346 assert "deferred" not in vars(record)
347
348 loaded = pickle.loads(pickle.dumps(record))
349 assert loaded.deferred == ()
350 assert loaded == ScopeRecord(
351 project="src", kind="file", target="a.cpp", fingerprint=()
352 )
353
354
355@pytest.mark.parametrize("layout", ["one-document", "two-documents"])
356def test_a_generated_id_is_defined_once(
357 tmp_path: Path, make_app: _MakeApp, layout: str
358) -> None:
359 """A one-line style without ``id``: the id Sphinx-Needs would generate is checked
360 like a written one (it used to abort with ``Unique ID could not be generated``)."""
361 if layout == "one-document":
362 files = {**IDLESS_FILES, **_scoped(None)}
363 append = {"docs/index.rst": _trace()}
364 owner, other = "index", "index"
365 else:
366 files = IDLESS_FILES
367 append = {"docs/page1.rst": _trace(), "docs/page2.rst": _trace()}
368 owner, other = "page1", "page2"
369 _project(tmp_path, files=files, append=append, **IDLESS) # type: ignore[arg-type]
370 app = _build(tmp_path, make_app)
371
372 needs = _json(app)["needs"]
373 assert [n for n, need in needs.items() if need["title"] == "first impl"] == [
374 GENERATED
375 ]
376 assert _owner(app, GENERATED) == owner
377 assert _duplicates(app) == [_duplicate(owner, other, need_id=GENERATED)]
378
379
380def test_a_generated_id_with_local_urls_links_its_source_page(
381 tmp_path: Path, make_app: _MakeApp
382) -> None:
383 """A one-line style without ``id`` and local URLs on (no remote URL): the build
384 goes on (it used to abort with ``KeyError: 'id'``), the card's local link names the
385 source page, and that page's ``[docs]`` anchor links back to the generated id
386 (#2082)."""
387 _project(
388 tmp_path,
389 files={**IDLESS_FILES, **_scoped(None)},
390 toml_extra=IDLESS["toml_extra"],
391 toml_replace=("set_remote_url = true", "set_remote_url = false"),
392 )
393 app = _build(tmp_path, make_app)
394
395 assert build_warnings(app) == [DANGLING]
396 needs = _json(app)["needs"]
397 assert needs[GENERATED]["local-url"] == "src/impl.cpp#L1"
398 index = Path(app.outdir, "index.html").read_text(encoding="utf-8")
399 assert _field_links(index, "local-url") == ["src/impl.html#L-1"]
400 page = Path(app.outdir, "src", "impl.html").read_text(encoding="utf-8")
401 assert re.findall(
402 r'<a class="viewcode-back" href="([^"]*)">\[docs\]</a>', page
403 ) == [f"../index.html#{GENERATED}"]
404
405
406@pytest.mark.parametrize("generated", [False, True], ids=["written-id", "generated-id"])
407def test_a_directive_moved_to_another_document(
408 tmp_path: Path, make_app: _MakeApp, generated: bool
409) -> None:
410 """No overlap at any time: ``page2``'s trace moves to ``page1`` in one incremental
411 build. ``page1`` is read first, while ``page2``'s old need is still in the store; it
412 is about to be purged, so ``page1`` defines the need (this used to abort with
413 ``duplicate_id``)."""
414 need_id = GENERATED if generated else "IMPL_1"
415 _project(
416 tmp_path,
417 files=IDLESS_FILES if generated else FILES,
418 append={"docs/page2.rst": _trace()},
419 **(IDLESS if generated else {}), # type: ignore[arg-type]
420 )
421 assert _owner(_build(tmp_path, make_app), need_id) == "page2"
422
423 _rewrite(tmp_path / "docs" / "page2.rst", ":file: impl.cpp", ":file: other.cpp")
424 page1 = tmp_path / "docs" / "page1.rst"
425 page1.write_text(page1.read_text(encoding="utf-8") + _trace(), encoding="utf-8")
426 _touch_later(page1)
427 app = _build(tmp_path, make_app, freshenv=False)
428
429 assert "0 added, 2 changed, 0 removed" in _status(app)
430 assert _owner(app, need_id) == "page1"
431 assert _duplicates(app) == []
432
433
434@pytest.mark.parametrize("capture_order", ["reverse", "forward"])
435def test_two_markers_on_one_row_with_one_id_the_leftmost_defines_it(
436 tmp_path: Path,
437 make_app: _MakeApp,
438 monkeypatch: pytest.MonkeyPatch,
439 capture_order: str,
440) -> None:
441 """Two one-line needs on one row of one file with one id: the leftmost is created and
442 the other is skipped (#2150).
443
444 The order tree-sitter returns a file's comments in differs between runs, but seldom
445 on any one row, so the build is rigged: ``extract_comments`` returns its comments
446 sorted by position, reversed, so that only the sort of the one-line needs can put
447 the left marker first. ``forward`` returns them in source order, the control.
448 ``test_analyse_order`` pins the order of ``oneline_needs`` over twenty runs too.
449 """
450 original = utils.extract_comments
451
452 def rigged(*args: Any, **kwargs: Any) -> list[Any] | None:
453 comments = original(*args, **kwargs)
454 if comments is None:
455 return None
456 return sorted(
457 comments,
458 key=lambda node: node.start_byte,
459 reverse=capture_order == "reverse",
460 )
461
462 monkeypatch.setattr(utils, "extract_comments", rigged)
463 _project(
464 tmp_path,
465 files={
466 **FILES,
467 "src/impl.cpp": "/* @left, IMPL_D, impl */ /* @right, IMPL_D, impl */\n",
468 },
469 append={"docs/index.rst": _trace()},
470 )
471 app = _build(tmp_path, make_app)
472
473 assert _json(app)["needs"]["IMPL_D"]["title"] == "left"
474 assert _duplicates(app) == [
475 _duplicate("index", "index", "src/impl.cpp:1", "IMPL_D")
476 ]
477
478
479def test_two_files_with_one_id_on_one_row_the_first_file_defines_it(
480 tmp_path: Path, make_app: _MakeApp
481) -> None:
482 """Two one-line needs with one id on the same row of two files: the first file's is
483 created, as before #2150, though the other sits further left -- the column orders
484 the markers of one file, never two files."""
485 _project(
486 tmp_path,
487 files={
488 **FILES,
489 **_scoped(None),
490 "src/impl.cpp": "int x; // @A, IMPL_D, impl\n",
491 "src/other.cpp": "// @B, IMPL_D, impl\n",
492 },
493 )
494 app = _build(tmp_path, make_app)
495
496 assert _json(app)["needs"]["IMPL_D"]["title"] == "A"
497 assert _duplicates(app) == [
498 _duplicate("index", "index", "src/other.cpp:1", "IMPL_D")
499 ]