[docs]  1# @Test suite for the URL fields' string links, TEST_URL_LINKS_1, test, [IMPL_URL_LINKS_1]
  2"""The ``local-url`` / ``remote-url`` string links, registered at ``config-inited``.
  3
  4The directive used to write them into the configuration at read time, which a ``-j N``
  5worker never hands back, which changed the configuration on every build, and which let
  6the last project read decide every need's remote link.
  7"""
  8
  9import json
 10import re
 11import subprocess
 12from collections.abc import Callable
 13from pathlib import Path, PureWindowsPath
 14from types import SimpleNamespace
 15
 16import pytest
 17import sphinx
 18from sphinx.testing.util import SphinxTestApp
 19from sphinx.util.parallel import parallel_available
 20
 21from sphinx_codelinks.sphinx_extension.directives.src_trace import (
 22    generate_remote_url,
 23    generate_str_link_name,
 24)
 25from sphinx_codelinks.sphinx_extension.string_links import url_string_link
 26from sphinx_needs.config import NeedsSphinxConfig
 27from sphinx_needs.string_links import compiled_string_links
 28from sphinx_needs_testkit import assert_no_warnings, build_warnings
 29
 30GITHUB = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"
 31GITLAB = "https://gitlab.example.com/demo/-/blob/{commit}/{path}?ref=b#L{line}"
 32GITWEB = "https://git.example.com/?p=demo.git;a=blob;f={path};hb={commit}#l{line}"
 33
 34#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 does not
 35_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
 36
 37#: a conf.py ``setup`` recording ``needs_string_links`` just before Sphinx-Needs
 38#: compiles them (its listener is at priority 551)
 39_PROBE = """
 40def setup(app):
 41    def probe(app, config):
 42        app._codelinks_probe = dict(config.needs_string_links)
 43    app.connect("config-inited", probe, priority=550)
 44"""
 45
 46
 47def _project(
 48    root: Path,
 49    *,
 50    patterns: dict[str, str],
 51    pages: int = 0,
 52    conf_extra: str = "",
 53) -> str:
 54    """A git repository with one source directory per project and a docs directory.
 55
 56    ``index`` traces the first project and ``later`` the second (if any); ``pages``
 57    more documents make a ``-j 2`` read genuinely parallel. Returns the commit.
 58    """
 59    docs = root / "docs"
 60    docs.mkdir()
 61    toml = ["[codelinks]", "set_local_url = true", "set_remote_url = true", ""]
 62    for index, (name, pattern) in enumerate(patterns.items()):
 63        src = root / f"src{name}"
 64        src.mkdir()
 65        (src / f"{name}.cpp").write_text(
 66            "int x = 0;\n" * index + f"// [[need in {name}, IMPL_{name.upper()}]]\n",
 67            encoding="utf-8",
 68        )
 69        toml += [
 70            f"[codelinks.projects.{name}]",
 71            f'remote_url_pattern = "{pattern}"',
 72            f"[codelinks.projects.{name}.source_discover]",
 73            f'src_dir = "../src{name}"',
 74            'comment_type = "cpp"',
 75            f"[codelinks.projects.{name}.analyse.oneline_comment_style]",
 76            'start_sequence = "[["',
 77            'end_sequence = "]]"',
 78            'needs_fields = [{ name = "title" }, { name = "id" }, '
 79            '{ name = "type", default = "impl" }]',
 80            "",
 81        ]
 82    (docs / "ubproject.toml").write_text("\n".join(toml), encoding="utf-8")
 83    (docs / "conf.py").write_text(
 84        "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
 85        "exclude_patterns = ['_build']\n"
 86        "needs_build_json = True\n" + conf_extra,
 87        encoding="utf-8",
 88    )
 89    names = list(patterns)
 90    toctree = ".. toctree::\n   :glob:\n\n   *\n\n" if pages or len(names) > 1 else ""
 91    (docs / "index.rst").write_text(
 92        f"Index\n=====\n\n{toctree}.. src-trace::\n   :project: {names[0]}\n",
 93        encoding="utf-8",
 94    )
 95    if len(names) > 1:
 96        (docs / "later.rst").write_text(
 97            f"Later\n=====\n\n.. src-trace::\n   :project: {names[1]}\n",
 98            encoding="utf-8",
 99        )
100    for page in range(pages):
101        (docs / f"page{page}.rst").write_text(
102            f"Page {page}\n=======\n\ntext\n", encoding="utf-8"
103        )
104    git = ["git", "-c", "user.name=t", "-c", "user.email=t@t"]
105    git += ["-c", "commit.gpgsign=false"]
106    subprocess.run([*git, "init", "--quiet"], cwd=root, check=True)
107    subprocess.run(
108        [*git, "remote", "add", "origin", "https://github.com/example/demo.git"],
109        cwd=root,
110        check=True,
111    )
112    subprocess.run([*git, "add", "-A"], cwd=root, check=True)
113    subprocess.run([*git, "commit", "--quiet", "-m", "init"], cwd=root, check=True)
114    return subprocess.run(
115        ["git", "rev-parse", "HEAD"],
116        cwd=root,
117        check=True,
118        capture_output=True,
119        text=True,
120    ).stdout.strip()
121
122
123def _card_links(html: str, field: str) -> list[str | None]:
124    """The href in each card's ``field`` row, or ``None`` for a row of plain text."""
125    rows = re.findall(
126        rf'needs_{re.escape(field)}"><span class="needs_label">[^<]*</span>'
127        r'<span class="needs_data">(.*?)</span></span>',
128        html,
129        re.S,
130    )
131    hrefs: list[str | None] = []
132    for row in rows:
133        found = re.search(r'href="([^"]*)"', row)
134        hrefs.append(found.group(1) if found else None)
135    return hrefs
136
137
138def _needs(app: SphinxTestApp) -> dict[str, dict[str, object]]:
139    data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
140    return data["versions"][data["current_version"]]["needs"]
141
142
143#: a one-line need at line 7, as ``_line_span`` reads it
144_NEED_AT_7 = SimpleNamespace(
145    source_map={"start": {"row": 6, "column": 0}, "end": {"row": 6, "column": 0}}
146)
147
148
149def test_remote_url_path_is_posix_for_windows_paths() -> None:
150    """A URL path is POSIX: built from Windows paths (``str()`` would give
151    ``srca\\a.cpp``), the filled-in pattern still holds ``srca/a.cpp``."""
152    out = PureWindowsPath("C:/docs/_build/html")
153    url = generate_remote_url(
154        _NEED_AT_7,  # ty: ignore[invalid-argument-type]
155        out / "srca" / "a.cpp",  # ty: ignore[invalid-argument-type]
156        {
157            "remote_src_dir": PureWindowsPath("srca"),
158            "target_dir": out / "srca",
159        },  # ty: ignore[invalid-argument-type]
160        GITHUB,
161        "a" * 40,
162    )
163    assert url == GITHUB.format(commit="a" * 40, path="srca/a.cpp", line=7)
164    assert "\\" not in url
165
166
167def test_local_url_value_is_posix_for_windows_paths() -> None:
168    """The local value becomes the link's href (``<value>.html#L-<line>``): POSIX too."""
169    value = generate_str_link_name(
170        _NEED_AT_7,  # ty: ignore[invalid-argument-type]
171        PureWindowsPath("..", "srca", "a.cpp"),  # ty: ignore[invalid-argument-type]
172    )
173    assert value == "../srca/a.cpp#L7"
174
175
176@pytest.mark.parametrize(
177    ("url", "name"),
178    [
179        pytest.param(
180            "https://github.com/o/r/blob/" + "a" * 40 + "/src/x.cpp#L3",
181            "src/x.cpp#L3",
182            id="github",
183        ),
184        pytest.param(
185            "https://gitlab.com/o/r/-/blob/" + "0123456789" * 4 + "/a/b.c#L10-L12",
186            "a/b.c#L10-L12",
187            id="gitlab-span",
188        ),
189        pytest.param(
190            "https://bitbucket.org/o/r/src/" + "f" * 64 + "/x.py#lines-2",
191            "x.py#lines-2",
192            id="bitbucket-sha256",
193        ),
194        pytest.param(
195            "https://example.com/src/main.py#L1",
196            "https://example.com/src/main.py#L1",
197            id="no-commit-falls-back-to-the-url",
198        ),
199        pytest.param(
200            "https://github.com/cafe1234/r/blob/None/x.cpp#L1",
201            "https://github.com/cafe1234/r/blob/None/x.cpp#L1",
202            id="no-git-rev-falls-back-to-the-url",
203        ),
204        pytest.param("foo", None, id="not-a-url"),
205        pytest.param("srca/a.cpp#L7", None, id="old-fragment-shape"),
206        pytest.param("javascript:alert(1)", None, id="scheme-without-slashes"),
207    ],
208)
209def test_url_string_link_names_the_location_after_the_commit(
210    url: str, name: str | None
211) -> None:
212    """The identity link targets the URL itself and is named by its ``path#Lline``
213    tail -- the part after a full commit hash -- or by the URL when there is none. A
214    value with no ``scheme://`` is not a URL: no match, so Sphinx-Needs renders text."""
215    entry = url_string_link("remote-url")
216    match = re.search(entry["regex"], url)
217    if name is None:
218        assert match is None
219        return
220    assert match is not None
221    groups = match.groupdict()
222    assert groups["codelinks_url"] == url
223    assert (groups["codelinks_location"] or groups["codelinks_url"]) == name
224
225
226def test_string_links_are_registered_before_sphinx_needs_compiles_them(
227    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
228) -> None:
229    """Both entries are in ``needs_string_links`` before Sphinx-Needs' listener at
230    priority 551 runs, are compiled by it, and render as links on the card."""
231    commit = _project(tmp_path, patterns={"a": GITHUB}, conf_extra=_PROBE)
232    app = make_app(srcdir=tmp_path / "docs", freshenv=True)
233
234    probe = app._codelinks_probe  # ty: ignore[unresolved-attribute]
235    # the URL fields, and the field @need-ids references are attached to
236    assert set(probe) == {"local-url", "remote-url", "code_url"}
237    assert probe["remote-url"] == url_string_link("remote-url")
238    compiled = compiled_string_links(NeedsSphinxConfig(app.config))
239    assert {"local-url", "remote-url", "code_url"} <= set(compiled)
240
241    app.build()
242    assert_no_warnings(app)
243    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
244    url = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
245    assert _card_links(html, "remote-url") == [url]
246    assert _card_links(html, "local-url") == ["srca/a.html#L-1"]
247    # the local link's name: the value up to the extension, and the line
248    assert '<a class="reference external" href="srca/a.html#L-1">srca/a#L1</a>' in html
249
250
251def test_user_string_links_are_kept_and_not_mutated(
252    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
253) -> None:
254    """codelinks adds its entries beside the user's, and rebinds the value rather than
255    writing into the conf.py dict."""
256    user_entry = (
257        "{'regex': r'^(?P<v>.+)$', 'link_url': '{{v}}', 'link_name': '{{v}}', "
258        "'options': ['title']}"
259    )
260    _project(
261        tmp_path,
262        patterns={"a": GITHUB},
263        conf_extra=f"USER_LINKS = {{'mine': {user_entry}}}\n"
264        "needs_string_links = USER_LINKS\n",
265    )
266    app = make_app(srcdir=tmp_path / "docs", freshenv=True)
267
268    assert set(app.config.needs_string_links) == {
269        "mine",
270        "local-url",
271        "remote-url",
272        "code_url",
273    }
274    assert set(app.config._raw_config["USER_LINKS"]) == {"mine"}
275
276
277@pytest.mark.skipif(
278    not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
279)
280def test_parallel_build_renders_the_url_links(
281    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
282) -> None:
283    """Under ``-j 2`` the card's URL rows are links, exactly as in a serial build."""
284    commit = _project(tmp_path, patterns={"a": GITHUB}, pages=7)
285    app = make_app(srcdir=tmp_path / "docs", freshenv=True, parallel=2)
286    app.build()
287
288    assert_no_warnings(app)
289    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
290    url = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
291    assert _card_links(html, "remote-url") == [url]
292    assert _card_links(html, "local-url") == ["srca/a.html#L-1"]
293
294
295def test_each_project_links_with_its_own_remote_url_pattern(
296    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
297) -> None:
298    """Two projects with different patterns: each need's card links with its own
299    project's pattern (it used to be the pattern of the project read last, for every
300    need), and ``needs.json`` holds that URL."""
301    commit = _project(tmp_path, patterns={"a": GITHUB, "b": GITLAB})
302    app = make_app(srcdir=tmp_path / "docs", freshenv=True)
303    app.build()
304
305    assert_no_warnings(app)
306    url_a = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
307    url_b = GITLAB.format(commit=commit, path="srcb/b.cpp", line=2)
308    # the cards first: on master these name the last-read-wins defect
309    index = Path(app.outdir, "index.html").read_text(encoding="utf-8")
310    later = Path(app.outdir, "later.html").read_text(encoding="utf-8")
311    assert _card_links(index, "remote-url") == [url_a]
312    assert _card_links(later, "remote-url") == [url_b]
313    needs = _needs(app)
314    assert needs["IMPL_A"]["remote-url"] == url_a
315    assert needs["IMPL_B"]["remote-url"] == url_b
316    assert needs["IMPL_A"]["local-url"] == "srca/a.cpp#L1"
317
318
319def test_a_needs_string_links_that_is_not_a_dict_is_left_alone(
320    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
321) -> None:
322    """codelinks adds nothing to a ``needs_string_links`` that is not a dict; the build
323    goes on with Sphinx-Needs' own warning about it."""
324    _project(tmp_path, patterns={"a": GITHUB}, conf_extra="needs_string_links = []\n")
325    app = make_app(srcdir=tmp_path / "docs", freshenv=True)
326    app.build()
327
328    warnings = build_warnings(app)
329    assert len(warnings) == 1, warnings
330    assert "needs_string_links must be a dict, got []." in warnings[0]
331
332
333def test_rebuild_does_not_change_the_string_links(
334    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
335) -> None:
336    """An unchanged rebuild reads the same ``needs_string_links`` the last build wrote:
337    no "configuration has changed" naming it, and the links still render."""
338    commit = _project(tmp_path, patterns={"a": GITHUB})
339    make_app(srcdir=tmp_path / "docs", freshenv=True).build()
340    for _ in range(2):
341        app = make_app(srcdir=tmp_path / "docs", freshenv=False)
342        app.build()
343        status = app._status.getvalue()
344        assert "0 added, 0 changed, 0 removed" in status
345        assert "needs_string_links" not in status
346
347    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
348    url = GITHUB.format(commit=commit, path="srca/a.cpp", line=1)
349    assert _card_links(html, "remote-url") == [url]
350
351
352@pytest.mark.parametrize(
353    "pattern",
354    [
355        pytest.param(GITWEB, id="semicolon"),
356        pytest.param("https://example.com/{commit}/{path},view#L{line}", id="comma"),
357    ],
358)
359def test_pattern_with_a_separator_warns(
360    tmp_path: Path, make_app: Callable[..., SphinxTestApp], pattern: str
361) -> None:
362    """Sphinx-Needs splits a string-linked value on ``,`` and ``;``: a pattern holding
363    either cannot render as one link, and the configuration check says so."""
364    _project(tmp_path, patterns={"a": pattern})
365    app = make_app(srcdir=tmp_path / "docs", freshenv=True)
366    app.build()
367
368    suffix = " [codelinks.remote_url_pattern]" if _SHOWS_WARNING_TYPES else ""
369    assert build_warnings(app) == [
370        f"WARNING: Project 'a': remote_url_pattern {pattern!r} contains ',' or ';'. "
371        "Sphinx-Needs splits string-linked values on ',' and ';', so this pattern's "
372        f"links will not render as one link.{suffix}"
373    ]
374
375
376def test_pattern_separator_warning_needs_remote_urls(
377    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
378) -> None:
379    """With remote URLs off the pattern is never filled in: no warning."""
380    _project(tmp_path, patterns={"a": GITWEB})
381    toml = tmp_path / "docs" / "ubproject.toml"
382    toml.write_text(
383        toml.read_text(encoding="utf-8").replace(
384            "set_remote_url = true", "set_remote_url = false"
385        ),
386        encoding="utf-8",
387    )
388    app = make_app(srcdir=tmp_path / "docs", freshenv=True)
389    app.build()
390
391    assert_no_warnings(app)
392
393
394def test_pattern_separator_warning_is_suppressible(
395    tmp_path: Path, make_app: Callable[..., SphinxTestApp]
396) -> None:
397    _project(
398        tmp_path,
399        patterns={"a": GITWEB},
400        conf_extra='suppress_warnings = ["codelinks.remote_url_pattern"]\n',
401    )
402    app = make_app(srcdir=tmp_path / "docs", freshenv=True)
403    app.build()
404
405    assert_no_warnings(app)