[docs]  1# @Test suite for incremental builds that see new source files, TEST_REDISCOVERY_1, test, [IMPL_LNK_1]
  2"""Incremental builds see the files a ``src-trace`` directive's scope gains or loses.
  3
  4Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``), with
  5the ``src-trace`` directive of ``index`` given the scope the case needs, then builds
  6again without ``-E``: a file added, removed or edited under the scope re-reads the
  7hosting document; a file outside the scope does not.
  8"""
  9
 10import os
 11import re
 12from pathlib import Path, PureWindowsPath
 13from typing import Any
 14
 15import pytest
 16from sphinx.testing.util import SphinxTestApp
 17from sphinx.util.console import strip_colors
 18from sphinx.util.parallel import parallel_available
 19
 20from .test_need_id_refs import (
 21    FIXTURE,
 22    _build,
 23    _card_links,
 24    _json,
 25    _MakeApp,
 26    _project,
 27    _refs,
 28    _url,
 29)
 30
 31INDEX = (FIXTURE / "docs" / "index.rst").read_text(encoding="utf-8")
 32DIRECTIVE = ".. src-trace::\n   :project: src\n"
 33assert DIRECTIVE in INDEX
 34
 35#: a one-line need and a reference, in a file the first build did not see
 36NEW_SOURCE = "// @new need, IMPL_NEW, impl, [REQ_002]\n// @need-ids: REQ_002\n"
 37
 38
 39def _scoped(option: str | None) -> dict[str, str]:
 40    """``files=`` for :func:`_project`: ``index``'s directive with ``option`` added."""
 41    directive = DIRECTIVE if option is None else f"{DIRECTIVE}   {option}\n"
 42    return {"docs/index.rst": INDEX.replace(DIRECTIVE, directive)}
 43
 44
 45def _status(app: SphinxTestApp) -> str:
 46    return strip_colors(app._status.getvalue())
 47
 48
 49def _touch_later(path: Path) -> None:
 50    later = path.stat().st_mtime + 10
 51    os.utime(path, (later, later))
 52
 53
 54def test_unchanged_rebuild_reads_nothing(tmp_path: Path, make_app: _MakeApp) -> None:
 55    """The fingerprint is stable: a rebuild with nothing touched re-reads nothing."""
 56    _project(tmp_path, files=_scoped(":directory: ."))
 57    _build(tmp_path, make_app)
 58    app = _build(tmp_path, make_app, freshenv=False)
 59
 60    assert "0 added, 0 changed, 0 removed" in _status(app)
 61
 62
 63def test_new_file_in_a_directory_scope_rereads_the_host(
 64    tmp_path: Path, make_app: _MakeApp
 65) -> None:
 66    """A file added under a ``:directory:`` scope is seen without ``-E``: its one-line
 67    need is created and its reference attached (#2040)."""
 68    commit = _project(tmp_path, files=_scoped(":directory: ."))
 69    first = _build(tmp_path, make_app)
 70    assert "IMPL_NEW" not in _json(first)["needs"]
 71
 72    (tmp_path / "src" / "new.cpp").write_text(NEW_SOURCE, encoding="utf-8")
 73    app = _build(tmp_path, make_app, freshenv=False)
 74
 75    assert "0 added, 1 changed, 0 removed" in _status(app)
 76    assert "IMPL_NEW" in _json(app)["needs"]
 77    assert _refs(app)["REQ_002"] == [
 78        _url(commit, 2, "src/new.cpp"),
 79        _url(commit, 3),
 80    ]
 81
 82
 83def test_new_file_in_a_subdirectory_of_the_scope_rereads_the_host(
 84    tmp_path: Path, make_app: _MakeApp
 85) -> None:
 86    """The walk is recursive: a nested addition counts (a directory's own mtime would
 87    not show it)."""
 88    _project(tmp_path, files={**_scoped(":directory: ."), "src/lib/a.cpp": "// a\n"})
 89    _build(tmp_path, make_app)
 90
 91    (tmp_path / "src" / "lib" / "deeper").mkdir()
 92    (tmp_path / "src" / "lib" / "deeper" / "new.cpp").write_text(
 93        NEW_SOURCE, encoding="utf-8"
 94    )
 95    app = _build(tmp_path, make_app, freshenv=False)
 96
 97    assert "0 added, 1 changed, 0 removed" in _status(app)
 98    assert "IMPL_NEW" in _json(app)["needs"]
 99
100
101def test_new_sibling_of_a_file_scope_rereads_nothing(
102    tmp_path: Path, make_app: _MakeApp
103) -> None:
104    """A ``:file:`` scope is that one file: a new file beside it costs nothing."""
105    _project(tmp_path, files=_scoped(":file: refs.cpp"))
106    _build(tmp_path, make_app)
107
108    (tmp_path / "src" / "sibling.cpp").write_text(NEW_SOURCE, encoding="utf-8")
109    app = _build(tmp_path, make_app, freshenv=False)
110
111    assert "0 added, 0 changed, 0 removed" in _status(app)
112    assert "IMPL_NEW" not in _json(app)["needs"]
113
114
115def test_removed_file_rereads_and_drops_its_records(
116    tmp_path: Path, make_app: _MakeApp
117) -> None:
118    commit = _project(
119        tmp_path,
120        files={
121            **_scoped(":directory: ."),
122            "src/new.cpp": NEW_SOURCE,
123        },
124    )
125    first = _build(tmp_path, make_app)
126    assert _refs(first)["REQ_002"] == [_url(commit, 2, "src/new.cpp"), _url(commit, 3)]
127
128    (tmp_path / "src" / "new.cpp").unlink()
129    app = _build(tmp_path, make_app, freshenv=False)
130
131    assert "0 added, 1 changed, 0 removed" in _status(app)
132    assert "IMPL_NEW" not in _json(app)["needs"]
133    assert _refs(app)["REQ_002"] == [_url(commit, 3)]
134
135
136@pytest.mark.parametrize("rule", ["exclude", "gitignore"])
137def test_new_file_the_discovery_rules_skip_rereads_nothing(
138    tmp_path: Path, make_app: _MakeApp, rule: str
139) -> None:
140    """The fingerprint walks exactly what the directive discovers."""
141    if rule == "exclude":
142        _project(
143            tmp_path,
144            files=_scoped(":directory: ."),
145            toml_replace=(
146                'src_dir = "../src"\n',
147                'src_dir = "../src"\nexclude = ["skipped/**"]\n',
148            ),
149        )
150    else:
151        _project(
152            tmp_path,
153            files={**_scoped(":directory: ."), "src/.gitignore": "skipped/\n"},
154        )
155    _build(tmp_path, make_app)
156
157    (tmp_path / "src" / "skipped").mkdir()
158    (tmp_path / "src" / "skipped" / "new.cpp").write_text(NEW_SOURCE, encoding="utf-8")
159    app = _build(tmp_path, make_app, freshenv=False)
160
161    assert "0 added, 0 changed, 0 removed" in _status(app)
162    assert "IMPL_NEW" not in _json(app)["needs"]
163
164
165def test_two_directives_over_one_scope_walk_it_once(
166    tmp_path: Path, make_app: _MakeApp, monkeypatch: pytest.MonkeyPatch
167) -> None:
168    """The check is memoised per scope within a build."""
169    from sphinx_codelinks.sphinx_extension import rediscovery
170
171    _project(
172        tmp_path,
173        append={"docs/later.rst": f"\n{DIRECTIVE}"},
174    )
175    _build(tmp_path, make_app)
176    walks: list[Any] = []
177    original = rediscovery.discover_scope
178
179    def counting(*args: Any, **kwargs: Any) -> Any:
180        walks.append(args)
181        return original(*args, **kwargs)
182
183    monkeypatch.setattr(rediscovery, "discover_scope", counting)
184    app = _build(tmp_path, make_app, freshenv=False)
185
186    assert "0 added, 0 changed, 0 removed" in _status(app)
187    assert len(walks) == 1
188
189
190@pytest.mark.skipif(
191    not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
192)
193def test_scopes_read_in_parallel_reach_the_main_process(
194    tmp_path: Path, make_app: _MakeApp
195) -> None:
196    """``-j 2`` records the scopes in a worker; ``env-merge-info`` brings them back, so the
197    next (serial) build still sees the new file."""
198    _project(tmp_path, files=_scoped(":directory: ."))
199    first = _build(tmp_path, make_app, parallel=2)
200    assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", _status(first))
201
202    (tmp_path / "src" / "new.cpp").write_text(NEW_SOURCE, encoding="utf-8")
203    app = _build(tmp_path, make_app, freshenv=False)
204
205    assert "0 added, 1 changed, 0 removed" in _status(app)
206    assert "IMPL_NEW" in _json(app)["needs"]
207
208
209def test_a_card_in_a_document_not_read_again_is_rewritten(
210    tmp_path: Path, make_app: _MakeApp
211) -> None:
212    """A source-only edit re-reads only the hosting document; ``REQ_002`` lives in
213    ``later``, which is not read again -- its card is still rewritten, and so it no
214    longer shows the reference the edit removed. ``later`` is written, not read."""
215    commit = _project(tmp_path)
216    first = _build(tmp_path, make_app)
217    assert _card_links(first, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
218
219    source = tmp_path / "src" / "refs.cpp"
220    source.write_text(
221        source.read_text(encoding="utf-8").replace(
222            "@need-ids: REQ_002, REQ_001", "@need-ids: REQ_001"
223        ),
224        encoding="utf-8",
225    )
226    _touch_later(source)
227    app = _build(tmp_path, make_app, freshenv=False)
228
229    status = _status(app)
230    assert "0 added, 1 changed, 0 removed" in status
231    assert _refs(app)["REQ_002"] is None
232    assert _card_links(app, "later.html") == []
233    assert not re.search(r"reading sources\.\.\. \[[^\]]*\] later\b", status)
234    assert re.search(r"writing output\.\.\. \[[^\]]*\] later\b", status)
235
236
237def test_fingerprint_is_the_discovered_files_with_mtime_and_size(
238    tmp_path: Path,
239) -> None:
240    from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
241    from sphinx_codelinks.sphinx_extension.rediscovery import fingerprint
242
243    (tmp_path / "lib").mkdir()
244    (tmp_path / "b.cpp").write_bytes(b"// b\n")
245    (tmp_path / "lib" / "a.cpp").write_bytes(b"// aa\n")
246    (tmp_path / "notes.txt").write_text("not a source\n", encoding="utf-8")
247    config = SourceDiscoverConfig(tmp_path, comment_type="cpp")
248
249    found = fingerprint(config, exclude=())
250    assert [(path, size) for path, _mtime, size in found] == [
251        ("b.cpp", 5),
252        ("lib/a.cpp", 6),
253    ]
254    assert fingerprint(config, exclude=()) == found
255    _touch_later(tmp_path / "b.cpp")
256    assert fingerprint(config, exclude=()) != found
257    assert fingerprint(SourceDiscoverConfig(tmp_path / "missing"), exclude=()) == ()
258
259
260def test_a_file_scope_fingerprints_that_one_file(tmp_path: Path) -> None:
261    from sphinx_codelinks.sphinx_extension.rediscovery import file_fingerprint
262
263    (tmp_path / "a.cpp").write_bytes(b"// a\n")
264    found = file_fingerprint(tmp_path, "a.cpp")
265    assert [(path, size) for path, _mtime, size in found] == [("a.cpp", 5)]
266    (tmp_path / "b.cpp").write_bytes(b"// b\n")
267    assert file_fingerprint(tmp_path, "a.cpp") == found
268    assert file_fingerprint(tmp_path, "missing.cpp") == ()
269
270
271def test_fingerprint_paths_are_posix_on_windows() -> None:
272    """The stored path is POSIX whatever the platform's separator."""
273    from sphinx_codelinks.sphinx_extension.rediscovery import scope_relative_path
274
275    path = PureWindowsPath("C:/work/src/lib/a.cpp")
276    assert scope_relative_path(path, PureWindowsPath("C:/work/src")) == "lib/a.cpp"
277
278
279def test_exclusion_stops_at_the_directory_boundary(tmp_path: Path) -> None:
280    """``_build2/`` is not under ``_build/``: the excluded prefix ends at a separator."""
281    from sphinx_codelinks.sphinx_extension.rediscovery import _outside
282
283    root = tmp_path.resolve()
284    files = [root / "_build" / "a.cpp", root / "_build2" / "a.cpp"]
285    assert _outside(files, [root / "_build"]) == [root / "_build2" / "a.cpp"]