[docs]  1# @Test suite for Sphinx extension source tracing functionality, TEST_EXT_1, test, [IMPL_LNK_1, IMPL_ONE_1, IMPL_MRST_1]
  2import os
  3import re
  4import shutil
  5from collections.abc import Callable
  6from dataclasses import fields
  7from pathlib import Path
  8
  9import pytest
 10import sphinx
 11from sphinx.environment import CONFIG_OK
 12from sphinx.testing.util import SphinxTestApp
 13
 14from sphinx_codelinks.analyse.oneline_parser import WarningSubTypeEnum
 15from sphinx_codelinks.config import (
 16    CodeLinksConfig,
 17    check_configuration,
 18)
 19from sphinx_codelinks.sphinx_extension.source_tracing import set_config_to_sphinx
 20from sphinx_needs_testkit import assert_no_warnings, build_warnings
 21
 22#: a one-line parser warning, by its kind: Sphinx 7 shows no ``[codelinks.oneline]``
 23_ONELINE_KIND = re.compile(
 24    rf"WARNING: ({'|'.join(kind.value for kind in WarningSubTypeEnum)}): "
 25)
 26
 27
 28@pytest.mark.parametrize(
 29    ("codelinks_config", "result"),
 30    [
 31        (
 32            {
 33                "remote_url_field": 555,
 34                "local_url_field": 789,
 35                "set_local_url": "fdd",
 36                "set_remote_url": "TrueString",
 37                "projects": {
 38                    "dcdc": {
 39                        "remote_url_pattern": 44332,
 40                        "source_discover": {
 41                            "comment_type": "java",
 42                            "src_dir": ["../dcdc"],
 43                            "exclude": [123],
 44                            "include": [345],
 45                            "gitignore": "_true",
 46                        },
 47                        "analyse": {
 48                            "oneline_comment_style": {
 49                                "start_sequence": "[[",
 50                                "end_sequence": "]]",
 51                                "field_split_char": ",",
 52                                "needs_fields": [
 53                                    {
 54                                        "name": "title",
 55                                        "type": "list[]",
 56                                    },
 57                                    {
 58                                        "name": "type",
 59                                        "default": "impl",
 60                                        "type": "str",
 61                                    },
 62                                ],
 63                            },
 64                        },
 65                    }
 66                },
 67            },
 68            [
 69                "Project 'dcdc' has the following errors:",
 70                "Schema validation error in field 'exclude': 123 is not of type 'string'",
 71                "Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'yaml']",
 72                "Schema validation error in field 'gitignore': '_true' is not of type 'boolean'",
 73                "Schema validation error in field 'include': 345 is not of type 'string'",
 74                "Schema validation error in field 'src_dir': ['../dcdc'] is not of type 'string'",
 75                "Schema validation error in filed 'local_url_field': 789 is not of type 'string'",
 76                "Schema validation error in filed 'remote_url_field': 555 is not of type 'string'",
 77                "Schema validation error in filed 'set_local_url': 'fdd' is not of type 'boolean'",
 78                "Schema validation error in filed 'set_remote_url': 'TrueString' is not of type 'boolean'",
 79                "OneLineCommentStyle configuration errors:",
 80                "Schema validation error in need_fields 'title': 'list[]' is not one of ['str', 'list[str]']",
 81                "remote_url_pattern must be a string",
 82            ],
 83        ),
 84        (
 85            {
 86                "remote_url_field": "remote-url",
 87                "local_url_field": "local-url",
 88                "set_local_url": True,
 89                "set_remote_url": True,
 90                "projects": {
 91                    "dcdc": {
 92                        # intentionally not given "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
 93                        "source_discover": {
 94                            "comment_type": "cpp",
 95                            "src_dir": "../dcdc",
 96                            "exclude": [],
 97                            "include": [],
 98                            "gitignore": True,
 99                        },
100                        "analyse": {
101                            "oneline_comment_style": {
102                                "start_sequence": "[[",
103                                "end_sequence": "]]",
104                                "field_split_char": ",",
105                                "needs_fields": [
106                                    {
107                                        "name": "title",
108                                        "type": "str",
109                                    },
110                                    {
111                                        "name": "type",
112                                        "default": "impl",
113                                        "type": "str",
114                                    },
115                                ],
116                            },
117                        },
118                    }
119                },
120            },
121            [
122                "Project 'dcdc' has the following errors:",
123                "remote_url_pattern must be given, as set_remote_url is enabled",
124            ],
125        ),
126    ],
127)
128def test_src_tracing_config_negative(
129    make_app: Callable[..., SphinxTestApp],
130    codelinks_config,
131    result,
132):
133    this_file_dir = Path(__file__).parent
134    sphinx_project = Path("data") / "sphinx"
135    app = make_app(srcdir=(this_file_dir / sphinx_project))
136    set_config_to_sphinx(codelinks_config, app.env.config)
137    codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
138    errors = check_configuration(codelinks_sphinx_config)
139    assert sorted(errors) == sorted(result)
140
141
142def test_src_tracing_config_positive(make_app: Callable[..., SphinxTestApp], tmp_path):
143    codelinks_config = {
144        "remote_url_field": "remote-url",
145        "local_url_field": "local-url",
146        "set_local_url": True,
147        "set_remote_url": True,
148        "outdir": tmp_path,
149        "projects": {
150            "dcdc": {
151                "source_discover": {
152                    "comment_type": "cpp",
153                    "src_dir": "../dcdc",
154                    "exclude": ["**/*.hpp"],
155                    "include": ["**/*.cpp"],
156                    "gitignore": True,
157                },
158                "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
159                "analyse": {
160                    "oneline_comment_style": {
161                        "start_sequence": "[[",
162                        "end_sequence": "]]",
163                        "field_split_char": ",",
164                        "needs_fields": [
165                            {
166                                "name": "title",
167                                "type": "str",
168                            },
169                            {
170                                "name": "type",
171                                "default": "impl",
172                                "type": "str",
173                            },
174                        ],
175                    },
176                },
177            }
178        },
179    }
180    this_file_dir = Path(__file__).parent
181    sphinx_project = Path("data") / "sphinx"
182    app = make_app(srcdir=(this_file_dir / sphinx_project))
183    set_config_to_sphinx(codelinks_config, app.env.config)
184    codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
185    errors = check_configuration(codelinks_sphinx_config)
186    assert not errors
187
188
189@pytest.mark.parametrize(
190    ("sphinx_project", "source_code"),
191    [
192        (Path("data") / "sphinx", Path("data") / "dcdc"),
193        (
194            Path("doc_test") / "recursive_dirs",
195            Path("doc_test") / "recursive_dirs" / "dummy_src_lv1",
196        ),
197        (
198            Path("doc_test") / "minimum_config",
199            Path("doc_test") / "minimum_config",
200        ),
201        (
202            Path("doc_test") / "id_required",
203            Path("doc_test") / "id_required",
204        ),
205        (
206            Path("doc_test") / "cs_basic",
207            Path("doc_test") / "cs_basic",
208        ),
209        (
210            Path("doc_test") / "go_basic",
211            Path("doc_test") / "go_basic",
212        ),
213    ],
214)
215def test_build_html(
216    tmpdir: Path,
217    make_app: Callable[..., SphinxTestApp],
218    sphinx_project,
219    source_code,
220    snapshot_doctree,
221):
222    this_file_dir = Path(__file__).parent
223
224    sphinx_src_dir = tmpdir / sphinx_project
225    shutil.copytree(
226        this_file_dir / sphinx_project,
227        sphinx_src_dir,
228        dirs_exist_ok=True,
229    )
230    shutil.copytree(
231        this_file_dir / source_code,
232        tmpdir / source_code,
233        dirs_exist_ok=True,
234    )
235
236    app: SphinxTestApp = make_app(
237        srcdir=Path(sphinx_src_dir),
238        freshenv=True,
239    )
240    app.build()
241
242    html = Path(app.outdir, "index.html").read_text()
243    assert html
244
245    # the fixtures warn about other things (no git root, unknown links), never about
246    # a one-line marker
247    assert [w for w in build_warnings(app) if _ONELINE_KIND.search(w)] == []
248
249    assert app.env.get_doctree("index") == snapshot_doctree
250
251
252def test_incremental_build_keeps_src_trace_projects_unchanged(
253    tmpdir: Path,
254    make_app: Callable[..., SphinxTestApp],
255) -> None:
256    """An incremental rebuild with no source changes must not invalidate the env.
257
258    Regression test for the ``src-trace`` directive mutating the ``analyse_config``
259    object stored inside the ``rebuild="env"`` ``src_trace_projects`` config value.
260    The mutated object (populated ``src_dir``/``src_files``) was persisted into
261    ``environment.pickle``, so every incremental build compared it against the
262    freshly generated (empty) config and reported
263    ``[config changed ('src_trace_projects')]``, forcing a full re-read.
264    """
265    this_file_dir = Path(__file__).parent
266    sphinx_project = Path("data") / "sphinx"
267    source_code = Path("data") / "dcdc"
268
269    sphinx_src_dir = Path(tmpdir) / sphinx_project
270    shutil.copytree(this_file_dir / sphinx_project, sphinx_src_dir, dirs_exist_ok=True)
271    shutil.copytree(
272        this_file_dir / source_code, Path(tmpdir) / source_code, dirs_exist_ok=True
273    )
274
275    # First build populates environment.pickle in the shared build dir.
276    make_app(srcdir=sphinx_src_dir, freshenv=True).build()
277
278    # Second build reuses the same build dir and loads the pickled environment.
279    app = make_app(srcdir=sphinx_src_dir, freshenv=False)
280
281    captured: dict[str, object] = {}
282
283    def capture_config_status(_app, env, _added, _changed, _removed):
284        # ``env-get-outdated`` fires during read() after the config comparison
285        # but before config_status is reset to CONFIG_OK at the end of read().
286        captured["status"] = env.config_status
287        captured["extra"] = env.config_status_extra
288        return []
289
290    app.connect("env-get-outdated", capture_config_status)
291    app.build()
292
293    assert captured["status"] == CONFIG_OK, (
294        f"incremental build wrongly invalidated the environment: "
295        f"config changed{captured.get('extra')}"
296    )
297
298
299@pytest.fixture
300def minimal_sphinx_project(tmp_path: Path) -> Path:
301    """Minimal Sphinx project with no TOML config file next to conf.py."""
302    (tmp_path / "conf.py").write_text(
303        "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
304        "exclude_patterns = ['_build']\n"
305    )
306    (tmp_path / "index.rst").write_text("Minimal project\n===============\n")
307    return tmp_path
308
309
310def test_config_from_toml_defaults_to_ubproject_toml() -> None:
311    """The default config file is the shared ubproject.toml (ubcode-pub#75)."""
312    config_field = next(
313        field for field in fields(CodeLinksConfig) if field.name == "config_from_toml"
314    )
315    assert config_field.default == "ubproject.toml"
316
317
318def test_default_ubproject_toml_is_loaded(
319    minimal_sphinx_project: Path,
320    make_app: Callable[..., SphinxTestApp],
321) -> None:
322    """An ubproject.toml next to conf.py is loaded without any conf.py entry."""
323    (minimal_sphinx_project / "ubproject.toml").write_text(
324        "[codelinks.projects.demo]\n"
325        'remote_url_pattern = "https://example.com/{commit}/{path}#L{line}"\n'
326        "\n"
327        "[codelinks.projects.demo.source_discover]\n"
328        'src_dir = "./"\n'
329    )
330    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
331    app.build()
332
333    assert "demo" in app.config.src_trace_projects
334    assert_no_warnings(app)
335
336
337def test_default_ubproject_toml_without_codelinks_section_is_silent(
338    minimal_sphinx_project: Path,
339    make_app: Callable[..., SphinxTestApp],
340) -> None:
341    """A default ubproject.toml used by other tools but without [codelinks] is
342    silently ignored instead of warning."""
343    (minimal_sphinx_project / "ubproject.toml").write_text(
344        "[needs]\nid_required = true\n"
345    )
346    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
347    app.build()
348
349    assert app.config.src_trace_projects == {}
350    assert_no_warnings(app)
351
352
353def test_missing_default_ubproject_toml_is_silent(
354    minimal_sphinx_project: Path,
355    make_app: Callable[..., SphinxTestApp],
356) -> None:
357    """Without an ubproject.toml next to conf.py, the conf.py configuration is
358    used and no warning is emitted."""
359    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
360    app.build()
361
362    assert app.config.src_trace_projects == {}
363    assert_no_warnings(app)
364
365
366def test_explicit_toml_config_missing_warns(
367    minimal_sphinx_project: Path,
368    make_app: Callable[..., SphinxTestApp],
369) -> None:
370    """An explicitly configured TOML file that does not exist still warns."""
371    conf_py = minimal_sphinx_project / "conf.py"
372    conf_py.write_text(
373        conf_py.read_text() + '\nsrc_trace_config_from_toml = "nonexistent.toml"\n'
374    )
375    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
376    app.build()
377
378    warnings = build_warnings(app)
379    assert len(warnings) == 1, warnings
380    assert "does not exist" in warnings[0]
381
382
383# -- the TOML reader: what a -D value, a corrupt file and the typed warnings do ----------
384
385#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 appends nothing, so on
386#: 7.4 the reader's warnings are asserted by their phrase and count only
387_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
388
389_LOAD_FAILED = "Failed to load source tracing configuration"
390
391
392def _write_conf(project: Path, extra: str) -> None:
393    conf_py = project / "conf.py"
394    conf_py.write_text(conf_py.read_text(encoding="utf-8") + extra, encoding="utf-8")
395
396
397def _assert_one_config_warning(app: SphinxTestApp, phrase: str) -> None:
398    warnings = build_warnings(app)
399    assert len(warnings) == 1, warnings
400    assert phrase in warnings[0]
401    if _SHOWS_WARNING_TYPES:
402        assert "[codelinks.config]" in warnings[0]
403
404
405@pytest.mark.parametrize(
406    ("key", "toml_value", "override"),
407    [
408        ("set_local_url", "true", False),
409        ("set_remote_url", "true", False),
410        ("local_url_field", '"toml-url"', "cli-url"),
411        ("remote_url_field", '"toml-remote"', "cli-remote"),
412        ("debug_measurement", "true", False),
413        ("debug_filters", "true", False),
414    ],
415)
416def test_command_line_override_beats_the_toml(
417    minimal_sphinx_project: Path,
418    make_app: Callable[..., SphinxTestApp],
419    key: str,
420    toml_value: str,
421    override: object,
422) -> None:
423    """``-D src_trace_<key>`` wins over the same key in ``[codelinks]``.
424
425    ``confoverrides`` is what ``-D`` becomes: Sphinx stores both in ``config.overrides``.
426    """
427    (minimal_sphinx_project / "ubproject.toml").write_text(
428        f"[codelinks]\n{key} = {toml_value}\n", encoding="utf-8"
429    )
430    app = make_app(
431        srcdir=minimal_sphinx_project,
432        freshenv=True,
433        confoverrides={f"src_trace_{key}": override},
434    )
435
436    assert app.config[f"src_trace_{key}"] == override
437
438
439def test_refused_outdir_override_keeps_the_toml_value(
440    minimal_sphinx_project: Path,
441    make_app: Callable[..., SphinxTestApp],
442) -> None:
443    """Sphinx refuses ``-D src_trace_outdir`` (its default is a ``Path``: "unsupported
444    type") but keeps it in ``config.overrides``: an override that was never applied must
445    not suppress the TOML value."""
446    (minimal_sphinx_project / "ubproject.toml").write_text(
447        '[codelinks]\noutdir = "toml-out"\n', encoding="utf-8"
448    )
449    app = make_app(
450        srcdir=minimal_sphinx_project,
451        freshenv=True,
452        confoverrides={"src_trace_outdir": "cli-out"},
453    )
454
455    assert app.config.src_trace_outdir == "toml-out"
456
457
458def test_not_overridable_from_d_is_exactly_what_sphinx_refuses(
459    minimal_sphinx_project: Path,
460    make_app: Callable[..., SphinxTestApp],
461) -> None:
462    """The keys exempt from the ``-D`` skip are exactly the codelinks confvals Sphinx
463    refuses to take from ``-D`` -- detected per confval by Sphinx's own
464    "cannot override ... 'src_trace_<key>'" warning, with a value ("1") every confval
465    Sphinx does accept can convert."""
466    from sphinx_codelinks.sphinx_extension.source_tracing import (
467        NOT_OVERRIDABLE_FROM_D,
468    )
469
470    refused = set()
471    for item in fields(CodeLinksConfig):
472        app = make_app(
473            srcdir=minimal_sphinx_project,
474            freshenv=True,
475            confoverrides={f"src_trace_{item.name}": "1"},
476        )
477        if any(
478            "cannot override" in warning and f"'src_trace_{item.name}'" in warning
479            for warning in build_warnings(app)
480        ):
481            refused.add(item.name)
482
483    assert refused == set(NOT_OVERRIDABLE_FROM_D)
484
485
486def test_override_of_one_key_leaves_the_other_toml_keys(
487    minimal_sphinx_project: Path,
488    make_app: Callable[..., SphinxTestApp],
489) -> None:
490    """``-D`` on one key skips that key only: the rest of ``[codelinks]`` still loads."""
491    (minimal_sphinx_project / "ubproject.toml").write_text(
492        "[codelinks]\n"
493        "set_local_url = true\n"
494        'local_url_field = "toml-url"\n'
495        "[codelinks.projects.tomlproj.source_discover]\n"
496        'src_dir = "./"\n',
497        encoding="utf-8",
498    )
499    app = make_app(
500        srcdir=minimal_sphinx_project,
501        freshenv=True,
502        confoverrides={"src_trace_set_local_url": False},
503    )
504
505    assert app.config.src_trace_set_local_url is False
506    assert app.config.src_trace_local_url_field == "toml-url"
507    assert list(app.config.src_trace_projects) == ["tomlproj"]
508
509
510def test_command_line_override_of_config_from_toml_beats_the_toml(
511    minimal_sphinx_project: Path,
512    make_app: Callable[..., SphinxTestApp],
513) -> None:
514    """The eighth key: a ``config_from_toml`` inside the file no longer rewrites the
515    file name given with ``-D``."""
516    (minimal_sphinx_project / "cl.toml").write_text(
517        '[codelinks]\nconfig_from_toml = "deep/x.toml"\n', encoding="utf-8"
518    )
519    app = make_app(
520        srcdir=minimal_sphinx_project,
521        freshenv=True,
522        confoverrides={"src_trace_config_from_toml": "cl.toml"},
523    )
524
525    assert app.config.src_trace_config_from_toml == "cl.toml"
526
527
528def test_bare_name_override_does_not_suppress_the_toml(
529    minimal_sphinx_project: Path,
530    make_app: Callable[..., SphinxTestApp],
531) -> None:
532    """``-D set_local_url=0`` names no confval -- Sphinx warns and ignores it -- so the
533    TOML value stands; only ``src_trace_<key>`` counts as an override."""
534    (minimal_sphinx_project / "ubproject.toml").write_text(
535        "[codelinks]\nset_local_url = true\n", encoding="utf-8"
536    )
537    app = make_app(
538        srcdir=minimal_sphinx_project,
539        freshenv=True,
540        confoverrides={"set_local_url": False},
541    )
542
543    assert app.config.src_trace_set_local_url is True
544
545
546def test_refused_projects_override_keeps_the_toml_projects(
547    minimal_sphinx_project: Path,
548    make_app: Callable[..., SphinxTestApp],
549) -> None:
550    """``-D src_trace_projects=x`` is refused by Sphinx (a dict cannot be overridden
551    whole) but stays in ``config.overrides``: the TOML's projects must still load."""
552    (minimal_sphinx_project / "ubproject.toml").write_text(
553        '[codelinks.projects.tomlproj.source_discover]\nsrc_dir = "./"\n',
554        encoding="utf-8",
555    )
556    app = make_app(
557        srcdir=minimal_sphinx_project,
558        freshenv=True,
559        confoverrides={"src_trace_projects": "x"},
560    )
561
562    assert list(app.config.src_trace_projects) == ["tomlproj"]
563
564
565@pytest.mark.parametrize(
566    ("override", "expected"),
567    [
568        pytest.param({"src_trace_local_url_field": "cli-url"}, "cli-url", id="D-wins"),
569        pytest.param({}, "toml-url", id="conf.py-vs-toml-unchanged"),
570    ],
571)
572def test_command_line_then_toml_then_conf_py(
573    minimal_sphinx_project: Path,
574    make_app: Callable[..., SphinxTestApp],
575    override: dict[str, str],
576    expected: str,
577) -> None:
578    """One key set in all three places: ``-D`` > TOML > conf.py."""
579    _write_conf(minimal_sphinx_project, 'src_trace_local_url_field = "conf-url"\n')
580    (minimal_sphinx_project / "ubproject.toml").write_text(
581        '[codelinks]\nlocal_url_field = "toml-url"\n', encoding="utf-8"
582    )
583    app = make_app(srcdir=minimal_sphinx_project, freshenv=True, confoverrides=override)
584
585    assert app.config.src_trace_local_url_field == expected
586
587
588def _syntax_error(path: Path) -> None:
589    path.write_text("[codelinks\n", encoding="utf-8")
590
591
592def _not_utf8(path: Path) -> None:
593    path.write_bytes(b'[codelinks]\nlocal_url_field = "caf\xe9"\n')
594
595
596def _a_directory(path: Path) -> None:
597    path.mkdir()
598
599
600def _not_a_table(path: Path) -> None:
601    path.write_text('codelinks = "x"\n', encoding="utf-8")
602
603
604_CORRUPT_FILES = [
605    pytest.param(_syntax_error, id="syntax-error"),
606    pytest.param(_not_utf8, id="not-utf8"),
607    pytest.param(_a_directory, id="a-directory"),
608    pytest.param(_not_a_table, id="codelinks-not-a-table"),
609]
610
611
612@pytest.mark.parametrize("corrupt", _CORRUPT_FILES)
613def test_corrupt_default_ubproject_toml_warns(
614    minimal_sphinx_project: Path,
615    make_app: Callable[..., SphinxTestApp],
616    corrupt: Callable[[Path], None],
617) -> None:
618    """A default ubproject.toml that exists but cannot be read or parsed warns, as any
619    configured file did at 1.4.0 -- only a missing file or table is silent."""
620    corrupt(minimal_sphinx_project / "ubproject.toml")
621    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
622    app.build()
623
624    assert app.config.src_trace_projects == {}
625    _assert_one_config_warning(app, _LOAD_FAILED)
626
627
628@pytest.mark.parametrize(
629    ("name", "conf_extra"),
630    [
631        pytest.param("ubproject.toml", "", id="default"),
632        pytest.param(
633            "cl.toml", 'src_trace_config_from_toml = "cl.toml"\n', id="explicit"
634        ),
635    ],
636)
637def test_too_deeply_nested_toml_warns_naming_the_file(
638    minimal_sphinx_project: Path,
639    make_app: Callable[..., SphinxTestApp],
640    name: str,
641    conf_extra: str,
642) -> None:
643    """The TOML parser can fail with an exception ub-project does not wrap (a
644    ``RecursionError``): still one ``codelinks.config`` warning, naming the file since
645    Python's text does not, and the build completes."""
646    _write_conf(minimal_sphinx_project, conf_extra)
647    (minimal_sphinx_project / name).write_text(
648        "x = " + "[" * 5000 + "]" * 5000 + "\n", encoding="utf-8"
649    )
650    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
651    app.build()
652
653    _assert_one_config_warning(app, _LOAD_FAILED)
654    assert name in build_warnings(app)[0]
655
656
657def test_explicit_toml_syntax_error_names_the_file_once(
658    minimal_sphinx_project: Path,
659    make_app: Callable[..., SphinxTestApp],
660) -> None:
661    """ub-project's message already names the file; the warning does not repeat it."""
662    _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n')
663    _syntax_error(minimal_sphinx_project / "cl.toml")
664    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
665    app.build()
666
667    _assert_one_config_warning(app, _LOAD_FAILED)
668    assert build_warnings(app)[0].count("cl.toml") == 1
669
670
671@pytest.mark.parametrize(
672    "state",
673    [
674        pytest.param(None, id="missing"),
675        pytest.param(
676            lambda path: path.write_text("[needs]\n", encoding="utf-8"),
677            id="no-codelinks-table",
678        ),
679    ],
680)
681def test_default_name_written_in_conf_py_is_still_the_default(
682    minimal_sphinx_project: Path,
683    make_app: Callable[..., SphinxTestApp],
684    state: Callable[[Path], object] | None,
685) -> None:
686    """``src_trace_config_from_toml = "ubproject.toml"`` names the shared default: absent
687    or without ``[codelinks]`` it is silent, as when the name is left unset."""
688    _write_conf(
689        minimal_sphinx_project, 'src_trace_config_from_toml = "ubproject.toml"\n'
690    )
691    if state is not None:
692        state(minimal_sphinx_project / "ubproject.toml")
693    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
694    app.build()
695
696    assert_no_warnings(app)
697
698
699@pytest.mark.parametrize(
700    ("state", "phrase"),
701    [
702        pytest.param(None, "does not exist", id="missing"),
703        pytest.param(
704            lambda path: path.write_text("[needs]\n", encoding="utf-8"),
705            "has no [codelinks] table",
706            id="no-codelinks-table",
707        ),
708    ],
709)
710def test_other_spelling_of_the_default_name_is_explicit(
711    minimal_sphinx_project: Path,
712    make_app: Callable[..., SphinxTestApp],
713    state: Callable[[Path], object] | None,
714    phrase: str,
715) -> None:
716    """The default is the value ``ubproject.toml`` EXACTLY: ``./ubproject.toml`` names
717    the same file but is an explicit configuration, so missing or without
718    ``[codelinks]`` it warns."""
719    _write_conf(
720        minimal_sphinx_project, 'src_trace_config_from_toml = "./ubproject.toml"\n'
721    )
722    if state is not None:
723        state(minimal_sphinx_project / "ubproject.toml")
724    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
725    app.build()
726
727    _assert_one_config_warning(app, phrase)
728
729
730def test_explicit_toml_without_codelinks_table_warns(
731    minimal_sphinx_project: Path,
732    make_app: Callable[..., SphinxTestApp],
733) -> None:
734    """An explicitly configured file without ``[codelinks]`` warns, and says so."""
735    _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n')
736    (minimal_sphinx_project / "cl.toml").write_text(
737        "[needs]\nid_required = true\n", encoding="utf-8"
738    )
739    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
740    app.build()
741
742    _assert_one_config_warning(app, "has no [codelinks] table")
743
744
745@pytest.mark.parametrize(
746    "state",
747    [
748        pytest.param(None, id="missing"),
749        pytest.param(_syntax_error, id="syntax-error"),
750        pytest.param(
751            lambda path: path.write_text("[needs]\n", encoding="utf-8"),
752            id="no-codelinks-table",
753        ),
754    ],
755)
756def test_reader_warnings_are_suppressible(
757    minimal_sphinx_project: Path,
758    make_app: Callable[..., SphinxTestApp],
759    state: Callable[[Path], object] | None,
760) -> None:
761    """Every warning of the reader carries ``codelinks.config``."""
762    _write_conf(
763        minimal_sphinx_project,
764        'src_trace_config_from_toml = "cl.toml"\n'
765        'suppress_warnings = ["codelinks.config"]\n',
766    )
767    if state is not None:
768        state(minimal_sphinx_project / "cl.toml")
769    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
770    app.build()
771
772    assert_no_warnings(app)
773
774
775_MARKER = "# @Found here {tag}, IMPL_{tag}, impl\n"
776
777
778def _traced_project(root: Path, conf_extra: str, files: dict[str, str]) -> None:
779    """A project whose index traces project ``p``; ``files`` maps a relative path to
780    its content, and a ``LINK:<target>`` content makes that path a symlink."""
781    (root / "conf.py").write_text(
782        "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
783        "exclude_patterns = ['_build']\n" + conf_extra,
784        encoding="utf-8",
785    )
786    (root / "index.rst").write_text(
787        "T\n=\n\n.. src-trace::\n   :project: p\n", encoding="utf-8"
788    )
789    for relative, content in files.items():
790        path = root / relative
791        path.parent.mkdir(parents=True, exist_ok=True)
792        if content.startswith("LINK:"):
793            try:
794                os.symlink(content[len("LINK:") :], path)
795            except (OSError, NotImplementedError) as error:
796                pytest.skip(f"cannot create a symlink here: {error}")
797        else:
798            path.write_text(content, encoding="utf-8")
799
800
801_PROJECT_P = '[codelinks.projects.p.source_discover]\nsrc_dir = "./src"\ncomment_type = "python"\n'
802
803
804def test_conf_py_projects_are_normalized_for_src_trace(
805    tmp_path: Path,
806    make_app: Callable[..., SphinxTestApp],
807) -> None:
808    """Projects declared in ``conf.py`` get the same typed config as TOML projects."""
809    _traced_project(
810        tmp_path,
811        "src_trace_config_from_toml = None\n"
812        "src_trace_projects = {\n"
813        "    'p': {\n"
814        "        'source_discover': {\n"
815        "            'src_dir': './src',\n"
816        "            'comment_type': 'python',\n"
817        "        },\n"
818        "        'analyse': {},\n"
819        "    },\n"
820        "}\n",
821        {"src/a.py": _MARKER.format(tag="CONFPY")},
822    )
823    app = make_app(srcdir=tmp_path, freshenv=True)
824    app.build()
825
826    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
827    assert "IMPL_CONFPY" in html
828
829
830def test_symlinked_toml_anchors_at_the_links_directory(
831    tmp_path: Path,
832    make_app: Callable[..., SphinxTestApp],
833) -> None:
834    """A relative path in a TOML reached through a symlink is anchored at the LINK's
835    directory -- ``confdir / Path(config_from_toml).parent`` -- not the target's."""
836    _traced_project(
837        tmp_path,
838        "src_trace_config_from_toml = 'cl.toml'\n",
839        {
840            "other/sub/real.toml": _PROJECT_P,
841            "cl.toml": "LINK:" + str(Path("other", "sub", "real.toml")),
842            "src/a.py": _MARKER.format(tag="LINKDIR"),
843            "other/sub/src/a.py": _MARKER.format(tag="REALDIR"),
844        },
845    )
846    app = make_app(srcdir=tmp_path, freshenv=True)
847    app.build()
848
849    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
850    assert "IMPL_LINKDIR" in html
851    assert "IMPL_REALDIR" not in html
852
853
854def test_config_from_toml_set_in_the_toml_moves_the_anchor(
855    tmp_path: Path,
856    make_app: Callable[..., SphinxTestApp],
857) -> None:
858    """A PIN of today's behaviour, not an endorsement: ``config_from_toml`` is a key the
859    TOML may set, and when it does the use-site anchor moves to that name's directory
860    (the named file is never read)."""
861    _traced_project(
862        tmp_path,
863        "",
864        {
865            "ubproject.toml": _PROJECT_P
866            + "[codelinks]\nconfig_from_toml = 'deep/x.toml'\n",
867            "src/a.py": _MARKER.format(tag="CONFDIR"),
868            "deep/src/a.py": _MARKER.format(tag="DEEPDIR"),
869        },
870    )
871    app = make_app(srcdir=tmp_path, freshenv=True)
872    app.build()
873
874    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
875    assert "IMPL_DEEPDIR" in html
876    assert "IMPL_CONFDIR" not in html