[docs]  1# @Test suite for references of projects without a src-trace directive, TEST_CONFIG_ONLY_REFS_1, test, [IMPL_LNK_1]
  2"""ubCode's config-only mode: a project with no ``src-trace`` directive still has its
  3``@need-ids:`` references attached.
  4
  5Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) whose
  6``index`` has NO ``src-trace`` directive, so project ``src`` is configured but traced by
  7nothing. Its ``refs.cpp`` references ``REQ_001`` (lines 1 and 3), ``REQ_002`` (line 3),
  8``NOSUCH_ID`` (line 5) and ``REQ_003`` (line 7).
  9"""
 10
 11import os
 12import re
 13from collections.abc import Iterator
 14from pathlib import Path
 15from typing import Any
 16
 17import pytest
 18from sphinx.errors import ExtensionError
 19from sphinx.testing.util import SphinxTestApp
 20from sphinx.util.console import strip_colors
 21from sphinx.util.parallel import parallel_available
 22
 23from sphinx_codelinks.analyse.analyse import SourceAnalyse
 24from sphinx_needs_testkit import assert_no_warnings, build_warnings
 25
 26from .test_need_id_refs import (
 27    _SHOWS_WARNING_TYPES,
 28    DANGLING,
 29    FIXTURE,
 30    _build,
 31    _card_links,
 32    _json,
 33    _MakeApp,
 34    _project,
 35    _refs,
 36    _url,
 37)
 38
 39INDEX = (FIXTURE / "docs" / "index.rst").read_text(encoding="utf-8")
 40DIRECTIVE = ".. src-trace::\n   :project: src\n"
 41assert DIRECTIVE in INDEX
 42#: ``index`` without its directive: project ``src`` is configuration only
 43NO_DIRECTIVE = {"docs/index.rst": INDEX.replace(DIRECTIVE, "")}
 44
 45
 46def _status(app: SphinxTestApp) -> str:
 47    return strip_colors(app._status.getvalue())
 48
 49
 50def _touch_later(path: Path) -> None:
 51    later = path.stat().st_mtime + 10
 52    os.utime(path, (later, later))
 53
 54
 55def _edit(path: Path, old: str, new: str) -> None:
 56    text = path.read_text(encoding="utf-8")
 57    assert old in text
 58    path.write_text(text.replace(old, new), encoding="utf-8")
 59    _touch_later(path)
 60
 61
 62@pytest.fixture
 63def analyses(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[str]]:
 64    """The names of the projects ``SourceAnalyse.run`` analysed, in order."""
 65    calls: list[str] = []
 66    original = SourceAnalyse.run
 67
 68    def counting(self: SourceAnalyse, *args: Any, **kwargs: Any) -> None:
 69        calls.append(self.name)
 70        original(self, *args, **kwargs)
 71
 72    monkeypatch.setattr(SourceAnalyse, "run", counting)
 73    yield calls
 74
 75
 76def _config_only_store(app: SphinxTestApp) -> dict[str, Any]:
 77    from sphinx_codelinks.sphinx_extension.rediscovery import config_only_refs_store
 78
 79    return config_only_refs_store(app.env)
 80
 81
 82def test_references_attach_without_a_directive(
 83    tmp_path: Path, make_app: _MakeApp
 84) -> None:
 85    commit = _project(tmp_path, files=NO_DIRECTIVE)
 86    app = _build(tmp_path, make_app)
 87
 88    refs = _refs(app)
 89    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
 90    assert refs["REQ_002"] == [_url(commit, 3)]
 91    assert refs["REQ_003"] == [_url(commit, 7)]
 92    assert refs["SPEC_001"] is None
 93    assert build_warnings(app) == [DANGLING]
 94    status = _status(app)
 95    assert "codelinks [src]: 1 file, 5 references\n" in status
 96    assert "codelinks [src]: 4 references attached, 1 unknown" in status
 97    assert _card_links(app, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
 98
 99
100def test_a_project_with_a_directive_is_not_scanned_again(
101    tmp_path: Path, make_app: _MakeApp, analyses: list[str]
102) -> None:
103    """The directive owns its project: the configuration pass skips it."""
104    commit = _project(tmp_path)
105    app = _build(tmp_path, make_app)
106
107    assert analyses == ["src"]
108    assert _config_only_store(app) == {}
109    assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
110    assert "codelinks [src]: 4 references attached, 1 unknown" in _status(app)
111
112
113def test_unchanged_rebuild_does_not_analyse(
114    tmp_path: Path, make_app: _MakeApp, analyses: list[str]
115) -> None:
116    commit = _project(tmp_path, files=NO_DIRECTIVE)
117    _build(tmp_path, make_app)
118    assert analyses == ["src"]
119
120    app = _build(tmp_path, make_app, freshenv=False)
121
122    assert analyses == ["src"]
123    assert "0 added, 0 changed, 0 removed" in _status(app)
124    assert _refs(app)["REQ_002"] == [_url(commit, 3)]
125
126
127def test_a_source_only_edit_is_analysed_once(
128    tmp_path: Path, make_app: _MakeApp, analyses: list[str]
129) -> None:
130    """The edit is analysed in the build that sees it; that build reads no document,
131    yet the environment is pickled (the affected page is written), so the next build
132    finds a matching fingerprint and analyses nothing."""
133    commit = _project(tmp_path, files=NO_DIRECTIVE)
134    _build(tmp_path, make_app)
135    _edit(
136        tmp_path / "src" / "refs.cpp",
137        "@need-ids: REQ_002, REQ_001",
138        "@need-ids: REQ_001",
139    )
140    analyses.clear()
141
142    edited = _build(tmp_path, make_app, freshenv=False)
143    assert analyses == ["src"]
144    assert "0 added, 0 changed, 0 removed" in _status(edited)
145    assert _refs(edited)["REQ_002"] is None
146
147    analyses.clear()
148    app = _build(tmp_path, make_app, freshenv=False)
149    assert analyses == []
150    assert _refs(app)["REQ_002"] is None
151    assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
152
153
154@pytest.mark.parametrize("change", ["edit", "new-file"])
155def test_a_source_change_refreshes_the_records(
156    tmp_path: Path, make_app: _MakeApp, change: str
157) -> None:
158    commit = _project(tmp_path, files=NO_DIRECTIVE)
159    _build(tmp_path, make_app)
160    if change == "edit":
161        _edit(
162            tmp_path / "src" / "refs.cpp",
163            "@need-ids: REQ_002, REQ_001",
164            "@need-ids: REQ_001",
165        )
166    else:
167        (tmp_path / "src" / "new.cpp").write_text(
168            "// @need-ids: SPEC_001\n", encoding="utf-8"
169        )
170    app = _build(tmp_path, make_app, freshenv=False)
171
172    refs = _refs(app)
173    if change == "edit":
174        assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
175        assert refs["REQ_002"] is None
176        assert refs["SPEC_001"] is None
177    else:
178        assert refs["REQ_002"] == [_url(commit, 3)]
179        assert refs["SPEC_001"] == [_url(commit, 1, "src/new.cpp")]
180
181
182def test_a_card_in_a_document_not_read_again_is_rewritten(
183    tmp_path: Path, make_app: _MakeApp
184) -> None:
185    """No document is read at all, and ``later`` -- hosting ``REQ_002`` -- is written:
186    its card no longer shows the reference the edit removed."""
187    commit = _project(tmp_path, files=NO_DIRECTIVE)
188    first = _build(tmp_path, make_app)
189    assert _card_links(first, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
190
191    _edit(
192        tmp_path / "src" / "refs.cpp",
193        "@need-ids: REQ_002, REQ_001",
194        "@need-ids: REQ_001",
195    )
196    app = _build(tmp_path, make_app, freshenv=False)
197
198    status = _status(app)
199    assert "0 added, 0 changed, 0 removed" in status
200    assert re.search(r"writing output\.\.\. \[[^\]]*\] later\b", status)
201    assert _card_links(app, "later.html") == []
202
203
204def test_removing_the_directive_attaches_from_the_configuration(
205    tmp_path: Path, make_app: _MakeApp
206) -> None:
207    """The build that drops the last directive of a project attaches its references
208    from the configuration pass already."""
209    commit = _project(tmp_path)
210    _build(tmp_path, make_app)
211    _edit(tmp_path / "docs" / "index.rst", DIRECTIVE, "")
212    app = _build(tmp_path, make_app, freshenv=False)
213
214    assert "0 added, 1 changed, 0 removed" in _status(app)
215    assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
216    assert set(_config_only_store(app)) == {"src"}
217
218
219def test_a_project_new_to_the_configuration_is_attached(
220    tmp_path: Path, make_app: _MakeApp
221) -> None:
222    gitlab = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
223    commit = _project(
224        tmp_path, files={"src2/more.cpp": "// @need-ids: REQ_001\nvoid more() {}\n"}
225    )
226    _build(tmp_path, make_app)
227    toml = tmp_path / "docs" / "ubproject.toml"
228    toml.write_text(
229        toml.read_text(encoding="utf-8")
230        + (
231            "\n[codelinks.projects.two]\n"
232            f'remote_url_pattern = "{gitlab}"\n'
233            "[codelinks.projects.two.source_discover]\n"
234            'src_dir = "../src2"\n'
235            'comment_type = "cpp"\n'
236        ),
237        encoding="utf-8",
238    )
239    app = _build(tmp_path, make_app, freshenv=False)
240
241    assert _refs(app)["REQ_001"] == [
242        _url(commit, 1),
243        _url(commit, 3),
244        gitlab.format(commit=commit, path="src2/more.cpp", line=1),
245    ]
246
247
248@pytest.mark.parametrize("gate", ["empty-field", "urls-off"])
249def test_the_gate_off_scans_nothing(
250    tmp_path: Path, make_app: _MakeApp, analyses: list[str], gate: str
251) -> None:
252    if gate == "empty-field":
253        replace = (
254            "[codelinks.projects.src]\n",
255            '[codelinks.projects.src]\nref_url_field = ""\n',
256        )
257    else:
258        replace = (
259            "set_local_url = true\nset_remote_url = true\n",
260            "set_local_url = false\nset_remote_url = false\n",
261        )
262    _project(tmp_path, files=NO_DIRECTIVE, toml_replace=replace)
263    app = _build(tmp_path, make_app)
264
265    assert analyses == []
266    assert _config_only_store(app) == {}
267    assert_no_warnings(app)
268    assert set(_refs(app).values()) == {"<absent>"}
269
270
271def test_a_missing_source_directory_warns_once_and_builds(
272    tmp_path: Path, make_app: _MakeApp
273) -> None:
274    _project(
275        tmp_path,
276        files=NO_DIRECTIVE,
277        toml_replace=('src_dir = "../src"', 'src_dir = "../nosuch"'),
278    )
279    suffix = " [codelinks.need_id_ref]" if _SHOWS_WARNING_TYPES else ""
280    for freshenv in (True, False):
281        app = _build(tmp_path, make_app, freshenv=freshenv)
282        # a second application in one process also re-registers Sphinx' own nodes
283        warnings = [w for w in build_warnings(app) if "is already registered" not in w]
284        assert len(warnings) == 1, warnings
285        assert warnings[0].startswith("WARNING: codelinks [src]: ")
286        assert "nosuch" in warnings[0]
287        assert "ConfigOnlyError: source directory" in warnings[0]
288        assert warnings[0].endswith(suffix)
289        assert set(_refs(app).values()) == {None}
290
291
292@pytest.mark.skipif(
293    not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
294)
295def test_parallel_build_attaches_and_pages_the_sources(
296    tmp_path: Path, make_app: _MakeApp
297) -> None:
298    """The configuration pass runs in the main process, so under ``-j 2`` the local
299    copies and their source pages are made (a directive's too, since #2044)."""
300    _project(
301        tmp_path,
302        files=NO_DIRECTIVE,
303        toml_replace=("set_remote_url = true", "set_remote_url = false"),
304    )
305    app = _build(tmp_path, make_app, parallel=2)
306
307    assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", _status(app))
308    refs = _refs(app)
309    assert refs["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
310    assert refs["REQ_003"] == ["../src/refs.cpp#L7"]
311    assert 'id="L-7"' in Path(app.outdir, "src", "refs.html").read_text(
312        encoding="utf-8"
313    )
314
315
316def test_one_line_needs_are_not_created_and_are_counted(
317    tmp_path: Path, make_app: _MakeApp
318) -> None:
319    """There is no directive to own them; ubCode creates none either."""
320    _project(
321        tmp_path,
322        files={
323            **NO_DIRECTIVE,
324            "src/impl.cpp": "// @implemented here, IMPL_CONFIG_ONLY\nvoid f() {}\n",
325        },
326    )
327    app = _build(tmp_path, make_app)
328
329    assert "IMPL_CONFIG_ONLY" not in _json(app)["needs"]
330    assert (
331        "codelinks [src]: 2 files, 5 references, 1 one-line need not created "
332        "(no src-trace directive)"
333    ) in _status(app)
334
335
336GITLAB = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
337
338
339@pytest.mark.parametrize("change", ["set_remote_url", "remote_url_pattern"])
340def test_a_configuration_change_analyses_again(
341    tmp_path: Path, make_app: _MakeApp, analyses: list[str], change: str
342) -> None:
343    """The fingerprint covers the files only: a configuration change (every document is
344    read again) analyses the project again too, so its records follow the new values."""
345    commit = _project(tmp_path, files=NO_DIRECTIVE)
346    _build(tmp_path, make_app)
347    toml = tmp_path / "docs" / "ubproject.toml"
348    if change == "set_remote_url":
349        _edit(toml, "set_remote_url = true", "set_remote_url = false")
350    else:
351        _edit(
352            toml, "https://github.com/example/demo/blob/", GITLAB.split("{commit}")[0]
353        )
354    analyses.clear()
355    app = _build(tmp_path, make_app, freshenv=False)
356
357    assert analyses == ["src"]
358    if change == "set_remote_url":
359        assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
360        assert _card_links(app, "later.html") == [
361            [("src/refs.html#L-3", "src/refs.cpp#L3")]
362        ]
363    else:
364        assert _refs(app)["REQ_001"] == [
365            GITLAB.format(commit=commit, path="src/refs.cpp", line=line)
366            for line in (1, 3)
367        ]
368
369
370def test_a_configuration_change_while_a_directive_owns_the_project(
371    tmp_path: Path, make_app: _MakeApp, analyses: list[str]
372) -> None:
373    """An entry ignored while a directive owned the project is not reused after a
374    configuration change once the directive goes: config-only, directive added, remote
375    URLs switched off, directive removed -- as ``-E`` would give."""
376    _project(tmp_path, files=NO_DIRECTIVE)
377    index = tmp_path / "docs" / "index.rst"
378    toml = tmp_path / "docs" / "ubproject.toml"
379    _build(tmp_path, make_app)
380    _edit(index, INDEX.replace(DIRECTIVE, ""), INDEX)
381    _build(tmp_path, make_app, freshenv=False)
382    _edit(toml, "set_remote_url = true", "set_remote_url = false")
383    _build(tmp_path, make_app, freshenv=False)
384    _edit(index, INDEX, INDEX.replace(DIRECTIVE, ""))
385    analyses.clear()
386    app = _build(tmp_path, make_app, freshenv=False)
387
388    assert analyses == ["src"]
389    assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
390
391
392def _repository_wide(tmp_path: Path, path: str, *, gitignore: bool = True) -> None:
393    """The fixture with ``src_dir = ".."`` -- the whole repository, docs and their
394    ``_build`` included, no ignore rule for it -- and local URLs only, so that every
395    HTML build copies the sources into its output."""
396    _project(
397        tmp_path,
398        files=NO_DIRECTIVE if path == "config-only" else None,
399        toml_replace=('src_dir = "../src"', 'src_dir = ".."'),
400    )
401    toml = tmp_path / "docs" / "ubproject.toml"
402    text = toml.read_text(encoding="utf-8").replace(
403        "set_remote_url = true", "set_remote_url = false"
404    )
405    if not gitignore:
406        text = text.replace(
407            'comment_type = "cpp"', 'comment_type = "cpp"\ngitignore = false'
408        )
409    toml.write_text(text, encoding="utf-8")
410
411
412def _discovered(app: SphinxTestApp, path: str) -> list[str]:
413    """What the last discovery of project ``src`` listed (relative to ``src_dir``)."""
414    if path == "config-only":
415        found = _config_only_store(app)["src"].fingerprint
416    else:
417        from sphinx_codelinks.sphinx_extension.rediscovery import scope_store
418
419        found = tuple(
420            e for scope in scope_store(app.env)["index"] for e in scope.fingerprint
421        )
422    return [relative for relative, _mtime, _size in found]
423
424
425def _duplicates(app: SphinxTestApp) -> list[str]:
426    return [w for w in build_warnings(app) if "duplicate" in w]
427
428
429@pytest.mark.parametrize("path", ["directive", "config-only"])
430def test_the_build_output_is_never_traced(
431    tmp_path: Path, make_app: _MakeApp, analyses: list[str], path: str
432) -> None:
433    """A source directory containing the output directory does not trace the
434    extension's own copies: nothing is analysed again, and each reference stays one
435    entry. On the directive path the hosting document is edited once, so the directive
436    runs again with the copies present (on the fresh build its discovery runs before it
437    writes them)."""
438    _repository_wide(tmp_path, path)
439    first = _build(tmp_path, make_app)
440    assert len(_refs(first)["REQ_003"]) == 1
441    if path == "directive":
442        # now, not later: a future mtime would re-read it on every build
443        (tmp_path / "docs" / "index.rst").touch()
444        rerun = _build(tmp_path, make_app, freshenv=False)
445        assert "0 added, 1 changed, 0 removed" in _status(rerun)
446        assert len(_refs(rerun)["REQ_003"]) == 1
447    analyses.clear()
448    for _ in range(3):
449        app = _build(tmp_path, make_app, freshenv=False)
450        assert "0 added, 0 changed, 0 removed" in _status(app)
451        assert len(_refs(app)["REQ_003"]) == 1
452
453    assert analyses == []
454    assert len(list(tmp_path.rglob("*.cpp"))) == 2
455
456
457@pytest.mark.parametrize("gitignore", [True, False], ids=["gitignore", "no-gitignore"])
458@pytest.mark.parametrize(
459    "builders",
460    [("html", "dirhtml", "html", "dirhtml"), ("html", "latex", "html")],
461    ids=["html-dirhtml", "html-latex"],
462)
463@pytest.mark.parametrize("path", ["directive", "config-only"])
464def test_other_builders_output_is_never_traced(
465    tmp_path: Path,
466    make_app: _MakeApp,
467    analyses: list[str],
468    path: str,
469    builders: tuple[str, ...],
470    gitignore: bool,
471) -> None:
472    """The Makefile layout -- ``_build/<builder>`` beside a shared ``_build/doctrees``:
473    one builder never traces another's copies -- with ``gitignore = true`` because an
474    ``.ignore`` sits at the root of every builder's output and doctree directories,
475    with ``gitignore = false`` because the build
476    directory inside the documentation source directory is skipped as a whole: nothing
477    is analysed again, each reference stays one entry, and no copy is ever made of a
478    copy. (The doctree directory's half of the rule is unobservable here: no source
479    file is ever written under it.) On the directive path the hosting document is
480    edited after the first build, so the directive runs again with another builder's
481    copies present."""
482    _repository_wide(tmp_path, path, gitignore=gitignore)
483    for number, builder in enumerate(builders):
484        if number == 1 and path == "directive":
485            # now, not later: a future mtime would re-read it on every build
486            (tmp_path / "docs" / "index.rst").touch()
487        if number == 2:
488            analyses.clear()
489        app = _build(tmp_path, make_app, buildername=builder, freshenv=number == 0)
490        needs_json = Path(app.outdir, "needs.json")
491        if needs_json.exists():
492            assert len(_refs(app)["REQ_003"]) == 1, builder
493        # each builder copies the source into its own output, once: no copy of a copy
494        copies = [p for p in tmp_path.rglob("*.cpp") if "_build" in p.parts]
495        assert all(p.parts.count("_build") == 1 for p in copies), copies
496
497    assert analyses == []
498
499
500@pytest.mark.parametrize(
501    "builders",
502    [("html", "dirhtml", "html", "dirhtml"), ("html", "latex", "html")],
503    ids=["html-dirhtml", "html-latex"],
504)
505@pytest.mark.parametrize("path", ["directive", "config-only"])
506def test_two_output_trees_outside_the_docs_never_trace_each_other(
507    tmp_path: Path,
508    make_app: _MakeApp,
509    path: str,
510    builders: tuple[str, ...],
511) -> None:
512    """``src_dir = "."`` at a repository root and the output in ``build/<builder>``,
513    outside the documentation source directory, with no ignore rule for ``build/``
514    (#2071): the ``.ignore`` at the root of every builder's output and doctree
515    directories keeps every builder's copies out of
516    discovery -- nothing under ``build/`` is ever listed, no copy is made of a copy,
517    each reference stays one entry, and no one-line need is defined twice."""
518    _repository_wide(tmp_path, path)
519    name = tmp_path.name
520    for number, builder in enumerate(builders):
521        if number == 1 and path == "directive":
522            # now, not later: a future mtime would re-read it on every build
523            (tmp_path / "docs" / "index.rst").touch()
524        app = _build(
525            tmp_path,
526            make_app,
527            buildername=builder,
528            builddir=tmp_path / "build",
529            freshenv=number == 0,
530        )
531        assert _discovered(app, path) == ["src/refs.cpp"], builder
532        if Path(app.outdir, "needs.json").exists():
533            assert len(_refs(app)["REQ_003"]) == 1, builder
534        assert _duplicates(app) == [], builder
535        copies = sorted(
536            p.relative_to(tmp_path).as_posix()
537            for p in (tmp_path / "build").rglob("*.cpp")
538        )
539        paged = sorted({b for b in builders[: number + 1] if b != "latex"})
540        assert copies == [f"build/{b}/{name}/src/refs.cpp" for b in paged], builder
541
542
543@pytest.mark.parametrize("path", ["directive", "config-only"])
544def test_an_output_directory_beside_traced_sources_hides_nothing(
545    tmp_path: Path, make_app: _MakeApp, path: str
546) -> None:
547    """The output and doctree directories placed directly inside a traced source
548    directory (``docs/code/html``, ``docs/code/doctrees``): with ``gitignore = true``
549    the sources beside them are traced -- only the two directories themselves are
550    skipped (#2065's caveat, gone) -- by the directive, by the scope walk of an
551    unchanged rebuild (which re-reads nothing) and by the config-only scan."""
552    files = {
553        "docs/code/beside.cpp": (
554            "// @need-ids: REQ_001\n// @beside the output, IMPL_BESIDE, impl\n"
555        )
556    }
557    _project(
558        tmp_path,
559        toml_replace=('src_dir = "../src"', 'src_dir = "code"'),
560        files={**files, **(NO_DIRECTIVE if path == "config-only" else {})},
561    )
562    toml = tmp_path / "docs" / "ubproject.toml"
563    _edit(toml, "set_remote_url = true", "set_remote_url = false")
564    builddir = tmp_path / "docs" / "code"
565    first = _build(tmp_path, make_app, builddir=builddir)
566    unchanged = _build(tmp_path, make_app, builddir=builddir, freshenv=False)
567
568    assert "0 added, 0 changed, 0 removed" in _status(unchanged)
569    assert _discovered(unchanged, path) == ["beside.cpp"]
570    assert _refs(unchanged)["REQ_001"] == ["code/beside.cpp#L1"]
571    if path == "config-only":
572        return
573    assert "IMPL_BESIDE" in _json(first)["needs"]
574    (tmp_path / "docs" / "index.rst").touch()
575    app = _build(tmp_path, make_app, builddir=builddir, freshenv=False)
576
577    assert "0 added, 1 changed, 0 removed" in _status(app)
578    assert _json(app)["needs"]["IMPL_BESIDE"]["docname"] == "index"
579    assert _duplicates(app) == []
580    copies = sorted(p.relative_to(builddir).as_posix() for p in builddir.rglob("*.cpp"))
581    assert copies == ["beside.cpp", "html/code/beside.cpp"]
582
583
584@pytest.mark.parametrize("builder", ["html", "latex"])
585def test_the_output_and_doctree_directories_hold_an_ignore_that_is_never_traced(
586    tmp_path: Path, make_app: _MakeApp, builder: str
587) -> None:
588    """Every builder writes ``.ignore`` (``*``) at the root of its output directory and
589    of the doctree directory, on every build: restored when deleted, an identical one
590    left alone, and itself never a source -- so with ``gitignore = true`` nothing any
591    builder writes is traced."""
592    from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
593    from sphinx_codelinks.sphinx_extension.rediscovery import fingerprint
594
595    _project(tmp_path, toml_replace=("set_remote_url = true", "set_remote_url = false"))
596    first = _build(tmp_path, make_app, buildername=builder)
597    roots = [Path(first.outdir), Path(first.doctreedir)]
598    markers = [root / ".ignore" for root in roots]
599    assert [m.read_bytes() for m in markers] == [b"*\n", b"*\n"]
600    left = markers[1].stat().st_mtime_ns
601    markers[0].unlink()
602
603    _build(tmp_path, make_app, buildername=builder, freshenv=False)
604
605    assert markers[0].read_bytes() == b"*\n"
606    assert markers[1].stat().st_mtime_ns == left
607    for root in roots:
608        for gitignore in (True, False):
609            listed = [
610                entry[0]
611                for entry in fingerprint(
612                    SourceDiscoverConfig(root, gitignore=gitignore, comment_type="cpp"),
613                    exclude=(),
614                )
615            ]
616            assert ".ignore" not in {Path(e).name for e in listed}, (root, gitignore)
617            if gitignore:
618                assert listed == [], root
619
620
621_DOWNLOAD = "\nThe implementation: :download:`impl.cpp <../src/impl.cpp>`.\n"
622
623
624def test_a_downloaded_source_in_another_builders_output_is_never_traced(
625    tmp_path: Path, make_app: _MakeApp
626) -> None:
627    """The Makefile layout, ``src_dir = ".."``, no ignore rule for ``_build``, the
628    default ``gitignore = true``, and ``index`` offering a traced source for download:
629    every HTML builder copies it into ``_downloads/``, and no builder traces another's
630    copy -- only ``src/`` is discovered, the need is defined once from ``src/impl.cpp``,
631    and the third build reads nothing."""
632    _repository_wide(tmp_path, "directive")
633    (tmp_path / "src" / "impl.cpp").write_text(
634        "// @first impl, IMPL_1, impl\n", encoding="utf-8"
635    )
636    index = tmp_path / "docs" / "index.rst"
637    index.write_text(index.read_text(encoding="utf-8") + _DOWNLOAD, encoding="utf-8")
638    name = tmp_path.name
639    for number, builder in enumerate(("html", "dirhtml", "html")):
640        if number == 1:
641            # now, not later: a future mtime would re-read it on every build
642            index.touch()
643        app = _build(tmp_path, make_app, buildername=builder, freshenv=number == 0)
644        assert list(Path(app.outdir, "_downloads").rglob("impl.cpp")), builder
645        assert _discovered(app, "directive") == ["src/impl.cpp", "src/refs.cpp"], (
646            builder
647        )
648        assert _duplicates(app) == [], builder
649        assert _json(app)["needs"]["IMPL_1"]["local-url"] == f"{name}/src/impl.cpp#L1"
650    assert "0 added, 0 changed, 0 removed" in _status(app)
651
652
653@pytest.mark.parametrize("state", ["unchanged", "failing-scan"])
654def test_a_build_with_nothing_to_redo_writes_nothing(
655    tmp_path: Path, make_app: _MakeApp, state: str
656) -> None:
657    """No document is written for a project that was not analysed again, nor for one
658    whose scan fails (it would fail again next build)."""
659    _project(
660        tmp_path,
661        files=NO_DIRECTIVE,
662        toml_replace=None
663        if state == "unchanged"
664        else ('src_dir = "../src"', 'src_dir = "../nosuch"'),
665    )
666    _build(tmp_path, make_app)
667    for _ in range(2):
668        app = _build(tmp_path, make_app, freshenv=False)
669        status = _status(app)
670        assert "0 added, 0 changed, 0 removed" in status
671        assert not re.search(r"writing output\.\.\. \[", status), status
672
673
674@pytest.mark.parametrize(
675    ("old", "new"),
676    [
677        ("return a + 1;", "return a + 12345;"),
678        ("void deep()", "void DEEP()"),
679        ("@need-ids: NOSUCH_ID", "@need-ids: NOSUCH_TWO"),
680    ],
681    ids=["code-only", "same-size-rename", "unknown-id"],
682)
683def test_an_edit_that_moves_no_reference_is_analysed_once(
684    tmp_path: Path, make_app: _MakeApp, analyses: list[str], old: str, new: str
685) -> None:
686    """No known need's references change, so no need's document is written; the root
687    document is, so the environment -- and the new scan -- is kept."""
688    commit = _project(tmp_path, files=NO_DIRECTIVE)
689    _build(tmp_path, make_app)
690    _edit(tmp_path / "src" / "refs.cpp", old, new)
691    counts = []
692    for build in range(4):
693        analyses.clear()
694        app = _build(tmp_path, make_app, freshenv=False)
695        counts.append(len(analyses))
696        if build == 0:
697            status = _status(app)
698            assert "pickling environment" in status
699            assert re.search(r"writing output\.\.\. \[[^\]]*\] index\b", status)
700        assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
701
702    assert counts == [1, 0, 0, 0]
703
704
705def test_a_programming_error_in_the_scan_fails_the_build(
706    tmp_path: Path, make_app: _MakeApp, monkeypatch: pytest.MonkeyPatch
707) -> None:
708    """Only the errors discovery and the analysis raise become a warning; a bug in the
709    extension is not read as a scan failure."""
710    from sphinx_codelinks.sphinx_extension import rediscovery
711
712    def broken(*_args: Any, **_kwargs: Any) -> Any:
713        raise KeyError("not a scan failure")
714
715    monkeypatch.setattr(rediscovery, "scan_config_only_project", broken)
716    _project(tmp_path, files=NO_DIRECTIVE)
717    with pytest.raises(ExtensionError) as raised:
718        _build(tmp_path, make_app)
719    assert isinstance(raised.value.orig_exc, KeyError)
720
721
722_EARLY_EXTENSION = """
723def setup(app):
724    from sphinx_needs.data import SphinxNeedsData
725
726    def resolve(app, env):
727        SphinxNeedsData(env).get_needs_view()
728
729    app.connect("env-updated", resolve)
730    return {"parallel_read_safe": True}
731"""
732
733
734def test_the_scan_runs_before_other_env_updated_handlers(
735    tmp_path: Path, make_app: _MakeApp
736) -> None:
737    """An extension loaded before this one whose ``env-updated`` handler resolves the
738    needs (post-processing, so the attach) still sees the config-only references."""
739    commit = _project(
740        tmp_path,
741        files={**NO_DIRECTIVE, "docs/early.py": _EARLY_EXTENSION},
742        append={
743            "docs/conf.py": (
744                "import os, sys\n"
745                "sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n"
746                'extensions.insert(1, "early")\n'
747            )
748        },
749    )
750    app = _build(tmp_path, make_app)
751
752    assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]