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