[docs]  1# @Test suite for need id references attached during the build, TEST_NEED_ID_REFS_1, test, [IMPL_LNK_1]
  2"""The references of ``@need-ids:`` markers, attached to the needs they name.
  3
  4Every case builds a copy of ``doc_test/need_id_refs`` made into a git repository, so the
  5remote URLs carry a real commit. ``src/refs.cpp`` references ``REQ_001`` (lines 1 and 3),
  6``REQ_002`` (line 3), the unknown ``NOSUCH_ID`` (line 5) and ``REQ_003`` (line 7);
  7``REQ_001`` is defined in ``index`` before the ``src-trace`` directive, ``REQ_002`` in
  8``later`` -- read after it -- and ``REQ_003`` in ``sub/deep``. ``SPEC_001`` is referenced
  9by nothing.
 10"""
 11
 12import codecs
 13import json
 14import os
 15import re
 16import shutil
 17import subprocess
 18import sys
 19from collections.abc import Callable
 20from pathlib import Path
 21from typing import Any
 22
 23import pytest
 24import sphinx
 25from sphinx.testing.util import SphinxTestApp
 26from sphinx.util.console import strip_colors
 27from sphinx.util.parallel import parallel_available
 28
 29from sphinx_codelinks.sphinx_extension.need_id_refs import need_id_refs_store
 30from sphinx_needs.data import SphinxNeedsData
 31from sphinx_needs_testkit import (
 32    assert_no_warnings,
 33    build_warnings,
 34    sphinx_build_command,
 35)
 36
 37FIXTURE = Path(__file__).parent / "doc_test" / "need_id_refs"
 38GITHUB = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"
 39
 40#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 does not
 41_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
 42
 43_MakeApp = Callable[..., SphinxTestApp]
 44
 45
 46def _git(root: Path, *args: str) -> str:
 47    command = ["git", "-c", "user.name=t", "-c", "user.email=t@t"]
 48    command += ["-c", "commit.gpgsign=false", *args]
 49    return subprocess.run(
 50        command, cwd=root, check=True, capture_output=True, text=True
 51    ).stdout.strip()
 52
 53
 54def _project(
 55    root: Path,
 56    *,
 57    toml_extra: str = "",
 58    toml_replace: tuple[str, str] | None = None,
 59    files: dict[str, str] | None = None,
 60    append: dict[str, str] | None = None,
 61    git: bool = True,
 62) -> str:
 63    """Copy the fixture into ``root``, change it, commit it; return the commit (or
 64    ``""`` with ``git=False``: no repository at all)."""
 65    shutil.copytree(FIXTURE, root, dirs_exist_ok=True)
 66    toml = root / "docs" / "ubproject.toml"
 67    text = toml.read_text(encoding="utf-8")
 68    if toml_replace is not None:
 69        assert toml_replace[0] in text
 70        text = text.replace(*toml_replace)
 71    toml.write_text(text + toml_extra, encoding="utf-8")
 72    for relative, content in (files or {}).items():
 73        path = root / relative
 74        path.parent.mkdir(parents=True, exist_ok=True)
 75        path.write_text(content, encoding="utf-8")
 76    for relative, content in (append or {}).items():
 77        path = root / relative
 78        path.write_text(path.read_text(encoding="utf-8") + content, encoding="utf-8")
 79    if not git:
 80        return ""
 81    _git(root, "init", "--quiet")
 82    _git(root, "remote", "add", "origin", "https://github.com/example/demo.git")
 83    _git(root, "add", "-A")
 84    _git(root, "commit", "--quiet", "-m", "init")
 85    return _git(root, "rev-parse", "HEAD")
 86
 87
 88def _build(root: Path, make_app: _MakeApp, **kwargs: Any) -> SphinxTestApp:
 89    kwargs.setdefault("freshenv", True)
 90    app = make_app(srcdir=root / "docs", **kwargs)
 91    app.build()
 92    return app
 93
 94
 95def _json(app: SphinxTestApp) -> dict[str, Any]:
 96    data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
 97    return data["versions"][data["current_version"]]
 98
 99
100def _refs(app: SphinxTestApp) -> dict[str, Any]:
101    """Each need's ``code_url``, or ``"<absent>"`` when needs.json has no such key."""
102    needs = _json(app)["needs"]
103    return {
104        need_id: need.get("code_url", "<absent>") for need_id, need in needs.items()
105    }
106
107
108def _url(commit: str, line: int, path: str = "src/refs.cpp") -> str:
109    return GITHUB.format(commit=commit, path=path, line=line)
110
111
112def _card_links(app: SphinxTestApp, page: str) -> list[list[tuple[str, str]]]:
113    """Per card on ``page``, the ``(href, name)`` of each link in its ``code_url`` row."""
114    html = Path(app.outdir, page).read_text(encoding="utf-8")
115    rows = re.findall(r'<span class="needs_code_url">(.*?)</div>', html, re.S)
116    return [re.findall(r'href="([^"]*)">([^<]*)</a>', row) for row in rows]
117
118
119def _warning(location: str, message: str) -> str:
120    suffix = " [codelinks.need_id_ref]" if _SHOWS_WARNING_TYPES else ""
121    return f"{location}: WARNING: {message}{suffix}"
122
123
124DANGLING = _warning("src/refs.cpp:5", "@need-ids reference to unknown need 'NOSUCH_ID'")
125
126
127def test_references_attach_to_needs_in_any_document(
128    tmp_path: Path, make_app: _MakeApp
129) -> None:
130    """Same document, a later one and a nested one alike; N references give N
131    entries, in line order; an unreferenced need carries nothing; nothing is
132    marked as modified; ``needs_schema`` declares the field as a list."""
133    commit = _project(tmp_path)
134    app = _build(tmp_path, make_app)
135
136    assert build_warnings(app) == [DANGLING]
137    refs = _refs(app)
138    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
139    assert refs["REQ_002"] == [_url(commit, 3)]
140    assert refs["REQ_003"] == [_url(commit, 7)]
141    assert refs["SPEC_001"] is None
142    needs = _json(app)["needs"]
143    assert not any(need["is_modified"] for need in needs.values())
144    schema = _json(app)["needs_schema"]["properties"]["code_url"]
145    assert schema["type"] == ["array", "null"]
146    assert schema["items"] == {"type": "string"}
147    assert schema["default"] is None
148
149    assert _card_links(app, "index.html") == [
150        [
151            (_url(commit, 1), "src/refs.cpp#L1"),
152            (_url(commit, 3), "src/refs.cpp#L3"),
153        ]
154    ]
155    assert _card_links(app, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
156    # the value is remote, so the reference-only file is neither copied nor paged
157    assert not Path(app.outdir, "src").exists()
158
159
160def test_unknown_id_warns_at_the_source_line(
161    tmp_path: Path, make_app: _MakeApp
162) -> None:
163    _project(tmp_path)
164    app = _build(tmp_path, make_app)
165
166    assert build_warnings(app) == [DANGLING]
167    status = app._status.getvalue()
168    assert "codelinks [src]: 4 references attached, 1 unknown" in status
169
170
171def test_unknown_id_warning_is_suppressible(tmp_path: Path, make_app: _MakeApp) -> None:
172    _project(tmp_path)
173    app = _build(
174        tmp_path,
175        make_app,
176        confoverrides={"suppress_warnings": ["codelinks.need_id_ref"]},
177    )
178
179    assert_no_warnings(app)
180    assert _refs(app)["REQ_002"] is not None
181
182
183@pytest.mark.skipif(
184    not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
185)
186def test_parallel_build_attaches_as_a_serial_one(
187    tmp_path: Path, make_app: _MakeApp
188) -> None:
189    """``-j 2`` reads the directive in a worker; its records reach the main process."""
190    serial_root = tmp_path / "serial"
191    parallel_root = tmp_path / "parallel"
192    commit = _project(serial_root)
193    _project(parallel_root)
194    serial = _build(serial_root, make_app)
195    parallel = _build(parallel_root, make_app, parallel=2)
196    # the read really was parallel: Sphinx reports chunks, "page1 .. page3"
197    status = strip_colors(parallel._status.getvalue())
198    assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", status)
199
200    def _normalised(app: SphinxTestApp) -> dict[str, Any]:
201        own_commit = _git(Path(app.srcdir).parent, "rev-parse", "HEAD")
202        text = json.dumps(_refs(app)).replace(own_commit, "<commit>")
203        return json.loads(text)
204
205    assert _refs(serial)["REQ_002"] == [_url(commit, 3)]
206    assert _normalised(parallel) == _normalised(serial)
207    # a second application in one process also re-registers Sphinx' own nodes
208    assert [w for w in build_warnings(parallel) if "@need-ids" in w] == [DANGLING]
209
210
211def test_unchanged_rebuild_keeps_the_references(
212    tmp_path: Path, make_app: _MakeApp
213) -> None:
214    """Nothing is re-read, and the references still attach, from the pickled env."""
215    commit = _project(tmp_path)
216    _build(tmp_path, make_app)
217    app = _build(tmp_path, make_app, freshenv=False)
218
219    assert "0 added, 0 changed, 0 removed" in app._status.getvalue()
220    refs = _refs(app)
221    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
222    assert refs["REQ_002"] == [_url(commit, 3)]
223
224
225def test_source_edit_clears_a_stale_reference(
226    tmp_path: Path, make_app: _MakeApp
227) -> None:
228    """Editing only the source re-reads the hosting document, whose old records are
229    dropped: ``REQ_002`` loses the reference the edit removed."""
230    commit = _project(tmp_path)
231    _build(tmp_path, make_app)
232    source = tmp_path / "src" / "refs.cpp"
233    source.write_text(
234        source.read_text(encoding="utf-8").replace(
235            "@need-ids: REQ_002, REQ_001", "@need-ids: REQ_001"
236        ),
237        encoding="utf-8",
238    )
239    later = source.stat().st_mtime + 10
240    os.utime(source, (later, later))
241    app = _build(tmp_path, make_app, freshenv=False)
242
243    assert "0 added, 1 changed, 0 removed" in app._status.getvalue()
244    refs = _refs(app)
245    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
246    assert refs["REQ_002"] is None
247
248
249def test_user_needextend_of_the_field_wins(tmp_path: Path, make_app: _MakeApp) -> None:
250    """The attach runs before ``needextend`` is applied: a user's extend overrides it."""
251    commit = _project(
252        tmp_path,
253        files={
254            "docs/override.rst": (
255                "Override\n========\n\n"
256                ".. needextend:: REQ_002\n   :code_url: user-override\n"
257            )
258        },
259    )
260    app = _build(tmp_path, make_app)
261
262    refs = _refs(app)
263    assert refs["REQ_002"] == ["user-override"]
264    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
265
266
267def test_strict_schema_never_sees_the_field_on_an_unreferenced_need(
268    tmp_path: Path, make_app: _MakeApp
269) -> None:
270    """``SPEC_001`` is validated by a schema with ``unevaluatedProperties: false``:
271    the field is unset there, so it is stripped before validation."""
272    _project(tmp_path)
273    app = _build(
274        tmp_path,
275        make_app,
276        confoverrides={"needs_schema_definitions_from_json": "schemas.json"},
277    )
278
279    report = json.loads(
280        Path(app.outdir, "schema_violations.json").read_text(encoding="utf-8")
281    )
282    assert report["validated_needs_count"] >= 1
283    assert report["validation_warnings"] == {}
284    assert build_warnings(app) == [DANGLING]
285
286
287def test_overlapping_directives_do_not_double_the_references(
288    tmp_path: Path, make_app: _MakeApp
289) -> None:
290    """A second directive over the same file analyses it again (one walk per
291    directive): its records are the same, and each reference is attached once."""
292    commit = _project(
293        tmp_path,
294        append={
295            "docs/later.rst": "\n.. src-trace::\n   :project: src\n   :file: refs.cpp\n"
296        },
297    )
298    app = _build(tmp_path, make_app)
299
300    refs = _refs(app)
301    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
302    assert refs["REQ_002"] == [_url(commit, 3)]
303    assert build_warnings(app) == [DANGLING]
304
305
306def test_two_projects_naming_one_field_share_its_list(
307    tmp_path: Path, make_app: _MakeApp
308) -> None:
309    gitlab = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
310    commit = _project(
311        tmp_path,
312        toml_extra=(
313            "\n[codelinks.projects.two]\n"
314            f'remote_url_pattern = "{gitlab}"\n'
315            "[codelinks.projects.two.source_discover]\n"
316            'src_dir = "../src2"\n'
317            'comment_type = "cpp"\n'
318        ),
319        files={"src2/more.cpp": "// @need-ids: REQ_001\nvoid more() {}\n"},
320        append={"docs/later.rst": "\n.. src-trace::\n   :project: two\n"},
321    )
322    app = _build(tmp_path, make_app)
323
324    assert _refs(app)["REQ_001"] == [
325        _url(commit, 1),
326        _url(commit, 3),
327        gitlab.format(commit=commit, path="src2/more.cpp", line=1),
328    ]
329    assert "codelinks [two]: 1 reference attached, 0 unknown" in app._status.getvalue()
330
331
332@pytest.mark.parametrize("remote", [True, False], ids=["remote-on", "local-only"])
333def test_empty_ref_url_field_attaches_nothing(
334    tmp_path: Path, make_app: _MakeApp, remote: bool
335) -> None:
336    """``ref_url_field = ""`` (ubCode's off switch): no field, no attach, no warning --
337    and the directive collects nothing and copies nothing for the project."""
338    _project(
339        tmp_path,
340        toml_replace=(
341            "[codelinks.projects.src]\n",
342            '[codelinks.projects.src]\nref_url_field = ""\n',
343        ),
344    )
345    if not remote:
346        toml = tmp_path / "docs" / "ubproject.toml"
347        toml.write_text(
348            toml.read_text(encoding="utf-8").replace(
349                "set_remote_url = true", "set_remote_url = false"
350            ),
351            encoding="utf-8",
352        )
353    app = _build(tmp_path, make_app)
354
355    assert need_id_refs_store(app.env) == {}
356    assert not Path(app.outdir, "src").exists()
357    assert_no_warnings(app)
358    assert "code_url" not in _json(app)["needs_schema"]["properties"]
359    assert set(_refs(app).values()) == {"<absent>"}
360    assert "code_url" not in app.config.needs_string_links
361
362
363def test_local_urls_when_remote_urls_are_off(
364    tmp_path: Path, make_app: _MakeApp
365) -> None:
366    """Without remote URLs an entry is the local one, relative to the need's own
367    document, and links to the source page generated beside the copied file."""
368    _project(tmp_path, toml_replace=("set_remote_url = true", "set_remote_url = false"))
369    app = _build(tmp_path, make_app)
370
371    refs = _refs(app)
372    assert refs["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
373    assert refs["REQ_003"] == ["../src/refs.cpp#L7"]
374    assert _card_links(app, "index.html") == [
375        [
376            ("src/refs.html#L-1", "src/refs.cpp#L1"),
377            ("src/refs.html#L-3", "src/refs.cpp#L3"),
378        ]
379    ]
380    assert _card_links(app, "sub/deep.html") == [
381        [("../src/refs.html#L-7", "../src/refs.cpp#L7")]
382    ]
383    page = Path(app.outdir, "src", "refs.html").read_text(encoding="utf-8")
384    assert 'id="L-7"' in page
385
386
387@pytest.mark.parametrize("local", [True, False], ids=["local-on", "local-off"])
388def test_no_remote_url_without_a_git_root(
389    tmp_path: Path, make_app: _MakeApp, local: bool
390) -> None:
391    """Outside a git repository nothing is written as a remote URL (#2045): the
392    created need has no ``remote-url``, and a reference falls back to the local link,
393    or attaches nothing when local URLs are off. The analysis warns once."""
394    _project(
395        tmp_path,
396        git=False,
397        toml_replace=None
398        if local
399        else ("set_local_url = true", "set_local_url = false"),
400        files={"src/impl.cpp": "// @implemented without git, IMPL_NOGIT\n"},
401    )
402    app = _build(tmp_path, make_app)
403
404    needs = _json(app)["needs"]
405    assert needs["IMPL_NOGIT"]["remote-url"] is None
406    refs = _refs(app)
407    if local:
408        assert refs["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
409    else:
410        assert refs["REQ_001"] is None
411    records = [ref for refs in need_id_refs_store(app.env).values() for ref in refs]
412    assert records and {ref.root for ref in records} == {"src_dir"}
413    git_root_warnings = [w for w in build_warnings(app) if "git root is not found" in w]
414    assert len(git_root_warnings) == 1, build_warnings(app)
415    assert not any("/blob/None" in json.dumps(need) for need in needs.values())
416
417
418@pytest.mark.parametrize("second", ["aaa", "two"])
419def test_two_projects_over_one_file_attach_each_reference_once(
420    tmp_path: Path, make_app: _MakeApp, second: str
421) -> None:
422    """Two projects whose source directories overlap analyse the same file: one entry
423    per reference, the FIRST PROJECT BY NAME keeping its URL -- whichever document is
424    read first (``aaa`` sorts before ``src`` but is traced in the later document) --
425    and the unknown id warned once."""
426    gitlab = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
427    commit = _project(
428        tmp_path,
429        toml_extra=(
430            f"\n[codelinks.projects.{second}]\n"
431            f'remote_url_pattern = "{gitlab}"\n'
432            f"[codelinks.projects.{second}.source_discover]\n"
433            'src_dir = "../src"\n'
434            'comment_type = "cpp"\n'
435        ),
436        append={"docs/later.rst": f"\n.. src-trace::\n   :project: {second}\n"},
437    )
438    app = _build(tmp_path, make_app)
439
440    refs = _refs(app)
441    if second == "aaa":
442        first = [
443            gitlab.format(commit=commit, path="src/refs.cpp", line=n) for n in (1, 3)
444        ]
445    else:
446        first = [_url(commit, 1), _url(commit, 3)]
447    assert refs["REQ_001"] == first
448    assert refs["REQ_002"] == first[1:]
449    assert build_warnings(app) == [DANGLING]
450
451
452def test_two_fields_over_one_file_each_keep_their_list(
453    tmp_path: Path, make_app: _MakeApp
454) -> None:
455    """Two projects over one file naming DIFFERENT fields: each field its own list (two
456    declared relations), and one unknown-id warning for the one marker."""
457    commit = _project(
458        tmp_path,
459        toml_extra=(
460            "\n[codelinks.projects.mirror]\n"
461            'ref_url_field = "test_url"\n'
462            f'remote_url_pattern = "{GITHUB}"\n'
463            "[codelinks.projects.mirror.source_discover]\n"
464            'src_dir = "../src"\n'
465            'comment_type = "cpp"\n'
466        ),
467        append={"docs/later.rst": "\n.. src-trace::\n   :project: mirror\n"},
468    )
469    app = _build(tmp_path, make_app)
470
471    needs = _json(app)["needs"]
472    assert needs["REQ_001"]["code_url"] == [_url(commit, 1), _url(commit, 3)]
473    assert needs["REQ_001"]["test_url"] == [_url(commit, 1), _url(commit, 3)]
474    assert build_warnings(app) == [DANGLING]
475
476
477def _two_roots(root: Path, *, git: bool) -> None:
478    """Two projects whose files share the relative path but are different files: two
479    git repositories (``repoa``, ``repob``), or two plain source directories."""
480    docs = root / "docs"
481    docs.mkdir(parents=True)
482    toml = [
483        "[codelinks]",
484        "set_local_url = true",
485        f"set_remote_url = {str(git).lower()}",
486    ]
487    for name in ("a", "b"):
488        src = root / f"repo{name}" / "src" if git else root / f"src{name}"
489        src.mkdir(parents=True)
490        (src / "main.cpp").write_text("// @need-ids: REQ_001\n", encoding="utf-8")
491        relative = f"../repo{name}/src" if git else f"../src{name}"
492        toml += [
493            f"[codelinks.projects.{name}]",
494            f'remote_url_pattern = "https://github.com/example/repo{name}/blob/'
495            '{commit}/{path}#L{line}"',
496            f"[codelinks.projects.{name}.source_discover]",
497            f'src_dir = "{relative}"',
498            'comment_type = "cpp"',
499        ]
500        if git:
501            repo = root / f"repo{name}"
502            _git(repo, "init", "--quiet")
503            _git(repo, "add", "-A")
504            _git(repo, "commit", "--quiet", "-m", "init")
505    (docs / "ubproject.toml").write_text("\n".join(toml) + "\n", encoding="utf-8")
506    (docs / "conf.py").write_text(
507        (FIXTURE / "docs" / "conf.py").read_text(encoding="utf-8"), encoding="utf-8"
508    )
509    (docs / "index.rst").write_text(
510        "Two roots\n=========\n\n.. req:: Cross-cutting\n   :id: REQ_001\n\n"
511        ".. src-trace::\n   :project: a\n\n.. src-trace::\n   :project: b\n",
512        encoding="utf-8",
513    )
514
515
516@pytest.mark.parametrize("git", [True, False], ids=["two-repositories", "two-src-dirs"])
517def test_same_relative_path_under_two_roots_is_two_files(
518    tmp_path: Path, make_app: _MakeApp, git: bool
519) -> None:
520    """``path`` is relative to each project's own root: ``src/main.cpp`` in two
521    repositories (or ``main.cpp`` in two source directories) is two files, so both
522    references are kept, and both projects report."""
523    _two_roots(tmp_path, git=git)
524    app = _build(tmp_path, make_app)
525
526    refs = _refs(app)["REQ_001"]
527    if git:
528        assert [url.split("/blob/")[0] for url in refs] == [
529            "https://github.com/example/repoa",
530            "https://github.com/example/repob",
531        ]
532        assert all(url.endswith("/src/main.cpp#L1") for url in refs)
533    else:
534        assert refs == ["srca/main.cpp#L1", "srcb/main.cpp#L1"]
535    status = app._status.getvalue()
536    assert "codelinks [a]: 1 reference attached, 0 unknown" in status
537    assert "codelinks [b]: 1 reference attached, 0 unknown" in status
538
539
540def test_no_remote_url_without_a_commit(tmp_path: Path, make_app: _MakeApp) -> None:
541    """A repository with no commit yet has no commit for the pattern: no remote URL,
542    no ``blob/None`` -- the local link instead (#2045)."""
543    _project(
544        tmp_path,
545        git=False,
546        files={"src/impl.cpp": "// @implemented before any commit, IMPL_NOCOMMIT\n"},
547    )
548    _git(tmp_path, "init", "--quiet")
549    _git(tmp_path, "remote", "add", "origin", "https://github.com/example/demo.git")
550    app = _build(tmp_path, make_app)
551
552    needs = _json(app)["needs"]
553    assert needs["IMPL_NOCOMMIT"]["remote-url"] is None
554    assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
555    assert not any("/blob/None" in json.dumps(need) for need in needs.values())
556
557
558def test_copies_are_byte_identical(tmp_path: Path, make_app: _MakeApp) -> None:
559    """With local URLs only, a referenced file and a file with a one-line need are
560    copied as bytes -- no codec, no newline translation -- and paged."""
561    refs = (FIXTURE / "src" / "refs.cpp").read_text(encoding="utf-8")
562    _project(
563        tmp_path,
564        toml_replace=("set_remote_url = true", "set_remote_url = false"),
565        files={
566            "src/refs.cpp": (refs + "// café: naïve ✓\n").replace("\n", "\r\n"),
567            "src/impl.cpp": "// @Implemented, IMPL_UTF8\r\n// déjà vu\r\n",
568        },
569    )
570    app = _build(tmp_path, make_app)
571
572    for name in ("refs.cpp", "impl.cpp"):
573        source = (tmp_path / "src" / name).read_bytes()
574        assert Path(app.outdir, "src", name).read_bytes() == source, name
575        page = Path(app.outdir, "src", name).with_suffix(".html")
576        assert page.exists(), name
577    assert "café: naïve ✓" in Path(app.outdir, "src", "refs.html").read_text(
578        encoding="utf-8"
579    )
580
581
582_INJECT = """
583def setup(app):
584    from sphinx_codelinks.analyse.references import NeedIdRef
585    from sphinx_codelinks.sphinx_extension.need_id_refs import need_id_refs_store
586
587    def inject(app, env, docnames):
588        need_id_refs_store(env)["<pre-analysed>"] = [
589            NeedIdRef(need_id=need_id, project="elsewhere", marker="@need-ids:",
590                      path="x.cpp", lineno=1, start_column=0, end_column=7,
591                      remote_url="https://example.com/x.cpp#L1")
592            for need_id in ("REQ_001", "REQ_002")
593        ]
594
595    app.connect("env-before-read-docs", inject)
596"""
597
598
599def test_records_of_an_unconfigured_project_warn_once(
600    tmp_path: Path, make_app: _MakeApp
601) -> None:
602    """Records reaching the store for a project this build does not configure (what a
603    pre-analysed file could carry) are ignored, with one warning per project."""
604    commit = _project(tmp_path, append={"docs/conf.py": _INJECT})
605    app = _build(tmp_path, make_app)
606
607    suffix = " [codelinks.need_id_ref]" if _SHOWS_WARNING_TYPES else ""
608    ignored = (
609        "WARNING: @need-ids records for project 'elsewhere', which this build does not "
610        f"configure, were ignored{suffix}"
611    )
612    assert sorted(build_warnings(app)) == sorted([DANGLING, ignored])
613    assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
614
615
616@pytest.mark.parametrize(
617    "declaration",
618    ['needs_fields = {"code_url": {}}', 'needs_extra_options = ["code_url"]'],
619    ids=["needs_fields", "needs_extra_options"],
620)
621def test_a_user_declaration_of_the_field_names_the_cure(
622    tmp_path: Path, make_app: _MakeApp, declaration: str
623) -> None:
624    """A ``needs_fields`` (or ``needs_extra_options``) entry for the references field
625    (the ``write rst`` route's habit) warns with the cause and the cure, beside
626    Sphinx-Needs' own duplicate."""
627    _project(tmp_path, append={"docs/conf.py": declaration + "\n"})
628    app = _build(tmp_path, make_app)
629
630    suffix = " [codelinks.config]" if _SHOWS_WARNING_TYPES else ""
631    assert (
632        "WARNING: codelinks registers 'code_url' for @need-ids references; remove the "
633        f"needs_fields declaration of it, or set ref_url_field{suffix}"
634    ) in build_warnings(app)
635
636
637def test_copies_and_pages_under_an_ascii_locale(tmp_path: Path) -> None:
638    """The byte copy and the source page do not depend on the locale's codec.
639
640    The in-process tests run under the suite's (UTF-8) locale, where a decode through the
641    locale codec cannot fail; this one builds in a subprocess under the C locale with
642    UTF-8 mode and locale coercion off, where it would (``UnicodeDecodeError: 'ascii'``).
643    Skipped only where the platform gives that environment a non-ASCII codec."""
644    env = {
645        **os.environ,
646        "LC_ALL": "C",
647        "LANG": "C",
648        "PYTHONUTF8": "0",
649        "PYTHONCOERCECLOCALE": "0",
650    }
651    env.pop("PYTHONIOENCODING", None)
652    probe = subprocess.run(
653        [sys.executable, "-c", "import locale; print(locale.getencoding())"],
654        env=env,
655        capture_output=True,
656        text=True,
657        check=True,
658    )
659    if codecs.lookup(probe.stdout.strip()).name != "ascii":
660        pytest.skip(f"the C locale's codec here is {probe.stdout.strip()!r}")
661    refs = (FIXTURE / "src" / "refs.cpp").read_text(encoding="utf-8")
662    _project(
663        tmp_path,
664        toml_replace=("set_remote_url = true", "set_remote_url = false"),
665        files={"src/refs.cpp": refs + "// café: naïve\n"},
666    )
667    out = tmp_path / "out"
668    result = subprocess.run(
669        sphinx_build_command("-b", "html", "-E", "-q", tmp_path / "docs", out),
670        env=env,
671        capture_output=True,
672        check=False,
673    )
674
675    assert result.returncode == 0, result.stderr.decode("utf-8", "replace")
676    source = (tmp_path / "src" / "refs.cpp").read_bytes()
677    assert (out / "src" / "refs.cpp").read_bytes() == source
678    assert "café: naïve" in (out / "src" / "refs.html").read_text(encoding="utf-8")
679
680
681def test_a_non_html_build_writes_no_source_copies(
682    tmp_path: Path, make_app: _MakeApp
683) -> None:
684    """The source copies and pages are written by the HTML builders only, at
685    ``html-collect-pages``: a LaTeX build of a local-only project -- a referenced file
686    and a file with a one-line need -- leaves no copy in its output, and the next HTML
687    build in the same process (an in-process server, a test run) builds as usual."""
688    _project(
689        tmp_path / "a",
690        toml_replace=("set_remote_url = true", "set_remote_url = false"),
691        files={"src/impl.cpp": "// @Implemented, IMPL_LATEX\n"},
692    )
693    _project(tmp_path / "b")
694    latex = _build(tmp_path / "a", make_app, buildername="latex")
695    assert "IMPL_LATEX" in SphinxNeedsData(latex.env).get_needs_view()
696    assert list(Path(latex.outdir).rglob("*.cpp")) == []
697    app = _build(tmp_path / "b", make_app)
698
699    # a second application in one process also re-registers Sphinx' own nodes
700    assert [w for w in build_warnings(app) if "@need-ids" in w] == [DANGLING]
701    assert Path(app.outdir, "index.html").exists()