[docs]  1# @Test suite for the code hop of every need src-trace creates, TEST_SOURCE_HOPS_1, test, [IMPL_LNK_1]
  2"""Where a need ``src-trace`` creates comes from: its ``code`` hop, then its page.
  3
  4Every need ``src-trace`` creates, one-line and multi-line, records innermost first the
  5marker in its source file (a ``code`` hop: the file under the Sphinx source directory,
  6the marker's 1-based lines, the entity it is attached to, outermost first, and the file's
  7language) and the page the directive is written on. Its ``lineno`` is the line the
  8directive is written on, after ``rst_prolog`` and in an included file too. The hops are
  9provisional, and reach a ``needs.json`` only in the experimental format 2a1.
 10"""
 11
 12from __future__ import annotations
 13
 14import dataclasses
 15import io
 16import json
 17import os
 18import pickle
 19import re
 20import sys
 21from collections.abc import Callable
 22from pathlib import Path
 23from typing import Any
 24
 25import pytest
 26from sphinx.testing.util import SphinxTestApp
 27from sphinx.util.console import strip_colors
 28
 29from sphinx_codelinks.analyse.entity import name_chain
 30from sphinx_needs.api import NeedItemSource, NeedSourceHop
 31from sphinx_needs.data import SphinxNeedsData
 32
 33_MakeApp = Callable[..., SphinxTestApp]
 34
 35# --- the project ----------------------------------------------------------------------
 36
 37PY = """\
 38# @Python file marker, IMPL_PY_FILE, impl
 39
 40class Shape:
 41    # @Area of a shape, IMPL_PY_ONE, impl
 42    def area(self):
 43        def inner():  # @Inner helper, IMPL_PY_LOCALS, impl
 44            pass
 45
 46
 47# @need impl: Python block
 48# :id: IMPL_PY_MULTI
 49#
 50# The body.
 51# @endneed
 52def compute():
 53    pass
 54"""
 55
 56#: the same method of the same class, in its class (here) and out of line (below)
 57CPP_NESTED = """\
 58namespace ns {
 59class W {
 60  public:
 61    // @In-class f, IMPL_CPP_IN, impl
 62    void f() {}
 63};
 64}  // namespace ns
 65
 66/**
 67 * @need impl: C++ block
 68 * :id: IMPL_CPP_MULTI
 69 *
 70 * The body.
 71 * @endneed
 72 */
 73int run() { return 0; }
 74"""
 75
 76CPP_OUT = """\
 77namespace ns {
 78class W {
 79  public:
 80    void f();
 81};
 82}  // namespace ns
 83
 84// @Out-of-line f, IMPL_CPP_OUT, impl
 85void ns::W::f() {}
 86"""
 87
 88C = """\
 89// @C add, IMPL_C_ONE, impl
 90int add(int a, int b) { return a + b; }
 91
 92/*
 93 * @need impl: C block
 94 * :id: IMPL_C_MULTI
 95 *
 96 * The body.
 97 * @endneed
 98 */
 99int sub(int a, int b) { return a - b; }
100"""
101
102#: a file of another suffix, traced by a C++ project (``:file:``): the project's language
103FOREIGN = """\
104// @Foreign suffix, IMPL_FOREIGN, impl
105
106int helper() { return 1; }
107"""
108
109RS = """\
110pub trait Shape {
111    fn area(&self) -> f64;
112}
113
114pub struct Widget;
115
116impl Shape for Widget {
117    // @Widget area, IMPL_RS_TRAIT, impl
118    fn area(&self) -> f64 {
119        1.0
120    }
121    // @need impl: Rust trait block
122    // :id: IMPL_RS_TRAIT_MULTI
123    //
124    // The body.
125    // @endneed
126    fn perimeter(&self) -> f64 {
127        2.0
128    }
129}
130
131impl Widget {
132    // @need impl: Rust block
133    // :id: IMPL_RS_MULTI
134    //
135    // The body.
136    // @endneed
137    pub fn new() -> Self {
138        Widget
139    }
140}
141"""
142
143TS = """\
144export class Greeter {
145  // @Greet, IMPL_TS_ONE, impl
146  greet(name: string): string {
147    return name;
148  }
149}
150
151/**
152 * @need impl: TypeScript block
153 * :id: IMPL_TS_MULTI
154 *
155 * The body.
156 * @endneed
157 */
158export function farewell(): void {}
159"""
160
161#: a YAML entry has no name: the hop has a language and no semantic location
162YAML = """\
163service:
164  # @YAML entry, IMPL_YAML_ONE, impl
165  port: 8080
166  # @need impl: YAML block
167  # :id: IMPL_YAML_MULTI
168  #
169  # The body.
170  # @endneed
171  host: localhost
172"""
173
174#: inside the Sphinx source directory: a path without ``..``
175INSIDE = """\
176# @Inside the source directory, IMPL_INSIDE, impl
177def inside():
178    pass
179"""
180
181#: CRLF line ends: lines count as they do with LF
182CRLF = (
183    b"class K:\r\n"
184    b"    # @CRLF method, IMPL_CRLF_ONE, impl\r\n"
185    b"    def m(self):\r\n"
186    b"        pass\r\n"
187    b"\r\n"
188    b"\r\n"
189    b"# @need impl: CRLF block\r\n"
190    b"# :id: IMPL_CRLF_MULTI\r\n"
191    b"#\r\n"
192    b"# The body.\r\n"
193    b"# @endneed\r\n"
194    b"def g():\r\n"
195    b"    pass\r\n"
196)
197
198TOML = """\
199[codelinks.projects.py.source_discover]
200src_dir = "../src/py"
201comment_type = "python"
202
203[codelinks.projects.cpp.source_discover]
204src_dir = "../src/cpp"
205comment_type = "cpp"
206
207[codelinks.projects.rust.source_discover]
208src_dir = "../src/rust"
209comment_type = "rust"
210
211[codelinks.projects.ts.source_discover]
212src_dir = "../src/ts"
213comment_type = "ts"
214
215[codelinks.projects.yaml.source_discover]
216src_dir = "../src/yaml"
217comment_type = "yaml"
218
219[codelinks.projects.inside.source_discover]
220src_dir = "code"
221comment_type = "python"
222""" + "".join(
223    f"""
224[codelinks.projects.{project}.analyse]
225get_oneline_needs = true
226get_multiline_needs = true
227"""
228    for project in ("py", "cpp", "rust", "ts", "yaml", "inside")
229)
230
231CONF = """\
232extensions = ["sphinx_needs", "sphinx_codelinks"]
233exclude_patterns = ["_build"]
234needs_build_json = True
235"""
236
237INDEX = """\
238Hops
239====
240
241.. src-trace::
242   :project: py
243
244.. src-trace::
245   :project: cpp
246
247.. src-trace::
248   :project: cpp
249   :file: tool.py
250
251Languages
252---------
253
254.. src-trace::
255   :project: rust
256
257.. src-trace::
258   :project: ts
259
260.. src-trace::
261   :project: yaml
262
263.. src-trace::
264   :project: inside
265"""
266
267FILES: dict[str, str | bytes] = {
268    "docs/conf.py": CONF,
269    "docs/ubproject.toml": TOML,
270    "docs/index.rst": INDEX,
271    "docs/code/inside.py": INSIDE,
272    "src/py/shapes.py": PY,
273    "src/py/crlf.py": CRLF,
274    "src/cpp/nested.cpp": CPP_NESTED,
275    "src/cpp/outofline.cpp": CPP_OUT,
276    "src/cpp/calc.c": C,
277    "src/cpp/tool.py": FOREIGN,
278    "src/rust/lib.rs": RS,
279    "src/ts/greeter.ts": TS,
280    "src/yaml/service.yaml": YAML,
281}
282
283
284def _write(root: Path, files: dict[str, str | bytes | None]) -> Path:
285    """Write the project under ``root``; return its Sphinx source directory. A file whose
286    content is the same is left untouched (Sphinx compares modification times), and
287    ``None`` removes one."""
288    for name, content in files.items():
289        path = root / name
290        if content is None:
291            path.unlink(missing_ok=True)
292            continue
293        data = content.encode("utf-8") if isinstance(content, str) else content
294        path.parent.mkdir(parents=True, exist_ok=True)
295        if not path.exists() or path.read_bytes() != data:
296            path.write_bytes(data)
297    return root / "docs"
298
299
300def _build(
301    make_app: _MakeApp, srcdir: Path, *, fresh: bool = True
302) -> tuple[SphinxTestApp, list[str], list[str]]:
303    """Build; return the app, the documents read and the documents written."""
304    app = make_app("html", srcdir=srcdir, freshenv=fresh, warning=io.StringIO())
305    app.build()
306    status = strip_colors(app._status.getvalue())
307    read = re.findall(r"reading sources\.\.\. \[\s*\d+%\] (\S+)", status)
308    written = re.findall(r"writing output\.\.\. \[\s*\d+%\] (\S+)", status)
309    return app, read, written
310
311
312def _compact(hop: NeedSourceHop) -> str:
313    """One hop on one line: ``kind [document] root:path:start-end (language) ::
314    semantic``, as sphinx-needs' own hop snapshots print them."""
315    parts = [hop.kind]
316    if hop.document is not None:
317        parts.append(f"[{hop.document}]")
318    if (physical := hop.physical) is not None:
319        where = f"{physical.root}:{physical.path}"
320        if physical.start_line is not None:
321            where += f":{physical.start_line}"
322            if physical.end_line is not None:
323                where += f"-{physical.end_line}"
324        parts.append(where)
325    if hop.language is not None:
326        parts.append(f"({hop.language})")
327    if hop.origin is not None:
328        parts.append(f"origin={hop.origin}")
329    if hop.semantic is not None:
330        parts.append(
331            ":: "
332            + " > ".join(
333                f"{entry.kind} {entry.name!r}"
334                + "".join(f" {key}={value!r}" for key, value in entry.extras)
335                for entry in hop.semantic
336            )
337        )
338    return " ".join(parts)
339
340
341@pytest.fixture
342def built(make_app: _MakeApp, tmp_path: Path) -> SphinxTestApp:
343    app, _, _ = _build(make_app, _write(tmp_path, FILES))
344    return app
345
346
347def _hops(app: SphinxTestApp) -> dict[str, list[str]]:
348    return {
349        need_id: [_compact(hop) for hop in need.source.hops]
350        for need_id, need in SphinxNeedsData(app.env).get_needs_view().items()
351    }
352
353
354# --- the hops ---------------------------------------------------------------------------
355
356_HOPS = "section 'Hops'"
357_LANGUAGES = "section 'Hops' > section 'Languages'"
358
359#: Every need's hops, innermost first: the marker in its file, then the directive.
360EXPECTED_HOPS = {
361    # attached to nothing (the file): the language from the comment type, no semantic
362    "IMPL_PY_FILE": [
363        "code srcdir:../src/py/shapes.py:1-1 (python)",
364        f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
365    ],
366    "IMPL_PY_ONE": [
367        "code srcdir:../src/py/shapes.py:4-4 (python) :: class 'Shape' > method 'area'"
368        " qualified_name='Shape.area'",
369        f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
370    ],
371    # Python's `<locals>` is implied by the kinds: a method another part follows
372    "IMPL_PY_LOCALS": [
373        "code srcdir:../src/py/shapes.py:6-6 (python) :: class 'Shape' > "
374        "method 'area' > function 'inner'"
375        " qualified_name='Shape.area.<locals>.inner'",
376        f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
377    ],
378    # a multi-line need: its open line to its close line
379    "IMPL_PY_MULTI": [
380        "code srcdir:../src/py/shapes.py:10-14 (python) :: function 'compute'"
381        " qualified_name='compute'",
382        f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
383    ],
384    "IMPL_CRLF_ONE": [
385        "code srcdir:../src/py/crlf.py:2-2 (python) :: class 'K' > method 'm'"
386        " qualified_name='K.m'",
387        f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
388    ],
389    "IMPL_CRLF_MULTI": [
390        "code srcdir:../src/py/crlf.py:7-11 (python) :: function 'g'"
391        " qualified_name='g'",
392        f"page [index] srcdir:index.rst:4-5 (.rst) :: {_HOPS}",
393    ],
394    # the pair: equal names, different kinds, carried as the analysis gives them
395    "IMPL_CPP_IN": [
396        "code srcdir:../src/cpp/nested.cpp:4-4 (cpp) :: namespace 'ns' > class 'W' > "
397        "method 'f'"
398        " qualified_name='ns::W::f'",
399        f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
400    ],
401    "IMPL_CPP_OUT": [
402        "code srcdir:../src/cpp/outofline.cpp:8-8 (cpp) :: scope 'ns' > class 'W' > "
403        "method 'f'"
404        " qualified_name='ns::W::f'",
405        f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
406    ],
407    "IMPL_CPP_MULTI": [
408        "code srcdir:../src/cpp/nested.cpp:10-14 (cpp) :: function 'run'"
409        " qualified_name='run'",
410        f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
411    ],
412    # a `.c` file is C, in a project of the cpp comment type
413    "IMPL_C_ONE": [
414        "code srcdir:../src/cpp/calc.c:1-1 (c) :: function 'add' qualified_name='add'",
415        f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
416    ],
417    "IMPL_C_MULTI": [
418        "code srcdir:../src/cpp/calc.c:5-9 (c) :: function 'sub' qualified_name='sub'",
419        f"page [index] srcdir:index.rst:7-8 (.rst) :: {_HOPS}",
420    ],
421    # a `.py` file traced by a C++ project: the project's language, not the suffix's
422    "IMPL_FOREIGN": [
423        "code srcdir:../src/cpp/tool.py:1-1 (cpp)",
424        f"page [index] srcdir:index.rst:10-12 (.rst) :: {_HOPS}",
425    ],
426    # a trait impl: the trait is an extra of the `impl` part
427    "IMPL_RS_TRAIT": [
428        "code srcdir:../src/rust/lib.rs:8-8 (rust) :: impl 'Widget' trait='Shape' > "
429        "method 'area'"
430        " qualified_name='<Widget as Shape>::area'",
431        f"page [index] srcdir:index.rst:17-18 (.rst) :: {_LANGUAGES}",
432    ],
433    # a multi-line need in a trait impl: the trait too
434    "IMPL_RS_TRAIT_MULTI": [
435        "code srcdir:../src/rust/lib.rs:12-16 (rust) :: impl 'Widget' trait='Shape' > "
436        "method 'perimeter'"
437        " qualified_name='<Widget as Shape>::perimeter'",
438        f"page [index] srcdir:index.rst:17-18 (.rst) :: {_LANGUAGES}",
439    ],
440    "IMPL_RS_MULTI": [
441        "code srcdir:../src/rust/lib.rs:23-27 (rust) :: impl 'Widget' > method 'new'"
442        " qualified_name='Widget::new'",
443        f"page [index] srcdir:index.rst:17-18 (.rst) :: {_LANGUAGES}",
444    ],
445    "IMPL_TS_ONE": [
446        "code srcdir:../src/ts/greeter.ts:2-2 (typescript) :: class 'Greeter' > "
447        "method 'greet'"
448        " qualified_name='Greeter.greet'",
449        f"page [index] srcdir:index.rst:20-21 (.rst) :: {_LANGUAGES}",
450    ],
451    "IMPL_TS_MULTI": [
452        "code srcdir:../src/ts/greeter.ts:9-13 (typescript) :: function 'farewell'"
453        " qualified_name='farewell'",
454        f"page [index] srcdir:index.rst:20-21 (.rst) :: {_LANGUAGES}",
455    ],
456    # a YAML entry has no name: no semantic location
457    "IMPL_YAML_ONE": [
458        "code srcdir:../src/yaml/service.yaml:2-2 (yaml)",
459        f"page [index] srcdir:index.rst:23-24 (.rst) :: {_LANGUAGES}",
460    ],
461    "IMPL_YAML_MULTI": [
462        "code srcdir:../src/yaml/service.yaml:4-8 (yaml)",
463        f"page [index] srcdir:index.rst:23-24 (.rst) :: {_LANGUAGES}",
464    ],
465    # inside the source directory: no `..`
466    "IMPL_INSIDE": [
467        "code srcdir:code/inside.py:1-1 (python) :: function 'inside'"
468        " qualified_name='inside'",
469        f"page [index] srcdir:index.rst:26-27 (.rst) :: {_LANGUAGES}",
470    ],
471}
472
473
474@pytest.mark.parametrize("need_id", list(EXPECTED_HOPS))
475def test_every_need_records_its_code_hop(built: SphinxTestApp, need_id: str) -> None:
476    assert _hops(built)[need_id] == EXPECTED_HOPS[need_id]
477
478
479def test_the_project_traces_exactly_the_pinned_needs(built: SphinxTestApp) -> None:
480    assert sorted(_hops(built)) == sorted(EXPECTED_HOPS)
481
482
483def test_the_cpp_pair_has_one_name_chain_and_two_kind_sequences(
484    built: SphinxTestApp,
485) -> None:
486    """The hop carries the parts as given; identity is codelinks' ``name_chain``."""
487    needs = SphinxNeedsData(built.env).get_needs_view()
488    nested, out = (needs[i].source.hops[0] for i in ("IMPL_CPP_IN", "IMPL_CPP_OUT"))
489    assert [p.kind for p in nested.semantic or ()] == ["namespace", "class", "method"]
490    assert [p.kind for p in out.semantic or ()] == ["scope", "class", "method"]
491
492    def chain(hop: NeedSourceHop) -> Any:
493        parts: list[Any] = [
494            {"kind": p.kind, "name": p.name, **dict(p.extras)}
495            for p in hop.semantic or ()
496        ]
497        return name_chain(parts, hop.language or "")
498
499    assert chain(nested) == chain(out)
500
501
502#: The entity's qualified name as the analysis renders it (``scope.qualified_name``), by
503#: its file's language: ``::`` for C, C++ and Rust (a trait impl ``<Widget as Shape>``),
504#: ``.`` otherwise, and Python's ``<locals>``.
505QUALIFIED = {
506    "IMPL_PY_ONE": "Shape.area",
507    "IMPL_PY_LOCALS": "Shape.area.<locals>.inner",
508    "IMPL_PY_MULTI": "compute",
509    "IMPL_CRLF_ONE": "K.m",
510    "IMPL_CRLF_MULTI": "g",
511    "IMPL_CPP_IN": "ns::W::f",
512    "IMPL_CPP_OUT": "ns::W::f",
513    "IMPL_CPP_MULTI": "run",
514    "IMPL_C_ONE": "add",
515    "IMPL_C_MULTI": "sub",
516    "IMPL_RS_TRAIT": "<Widget as Shape>::area",
517    "IMPL_RS_TRAIT_MULTI": "<Widget as Shape>::perimeter",
518    "IMPL_RS_MULTI": "Widget::new",
519    "IMPL_TS_ONE": "Greeter.greet",
520    "IMPL_TS_MULTI": "farewell",
521    "IMPL_INSIDE": "inside",
522}
523
524
525def test_the_entity_carries_the_analysis_qualified_name(built: SphinxTestApp) -> None:
526    """The innermost entry of a code hop, the entity itself, carries the analysis'
527    rendering of its qualified name as a ``("qualified_name", ...)`` extra, after a
528    trait; no other entry has one, and a hop with no entity has no semantic location."""
529    needs = SphinxNeedsData(built.env).get_needs_view()
530
531    def qualified(need_id: str) -> list[str | None]:
532        return [
533            dict(entry.extras).get("qualified_name")
534            for entry in needs[need_id].source.hops[0].semantic or ()
535        ]
536
537    assert {need_id: qualified(need_id)[-1] for need_id in QUALIFIED} == QUALIFIED
538    for need_id in EXPECTED_HOPS:
539        names = qualified(need_id)
540        assert (names == []) == (need_id not in QUALIFIED), need_id
541        assert all(name is None for name in names[:-1]), need_id
542    impl, method = needs["IMPL_RS_TRAIT"].source.hops[0].semantic or ()
543    assert impl.extras == (("trait", "Shape"),)
544    assert method.extras == (("qualified_name", "<Widget as Shape>::area"),)
545
546
547def test_a_need_s_lineno_is_its_directive_s_line(built: SphinxTestApp) -> None:
548    """The line the directive is written on: the page hop's start line."""
549    needs = SphinxNeedsData(built.env).get_needs_view()
550    assert {need_id: needs[need_id]["lineno"] for need_id in EXPECTED_HOPS} == {
551        need_id: int(re.findall(r"index\.rst:(\d+)-", hops[-1])[0])
552        for need_id, hops in EXPECTED_HOPS.items()
553    }
554
555
556def test_a_need_s_body_is_anchored_at_its_directive_s_line(
557    built: SphinxTestApp,
558) -> None:
559    """``lineno_content`` is the directive's line, one-line and multi-line alike, not
560    the line after its block where ``from_directive`` puts it: a body parsed at the page
561    without a ``content_source`` is the marker's, and is anchored at the directive
562    (``test_a_fallback_on_a_page_of_a_third_parser_keeps_the_need``)."""
563    needs = SphinxNeedsData(built.env).get_needs_view()
564    assert {
565        need_id: (
566            needs[need_id].source.lineno_content,
567            needs[need_id].source.parser_lineno,
568        )
569        for need_id in (
570            "IMPL_PY_ONE",
571            "IMPL_PY_MULTI",
572            "IMPL_RS_TRAIT",
573            "IMPL_RS_MULTI",
574        )
575    } == {
576        "IMPL_PY_ONE": (4, 4),
577        "IMPL_PY_MULTI": (4, 4),
578        "IMPL_RS_TRAIT": (17, 17),
579        "IMPL_RS_MULTI": (17, 17),
580    }
581
582
583def test_no_hop_reaches_needs_json(built: SphinxTestApp) -> None:
584    text = Path(built.outdir, "needs.json").read_text(encoding="utf-8")
585    assert "semantic" not in text
586    assert "hops" not in text
587    assert "shapes.py" not in text
588
589
590def test_the_code_hop_reaches_a_2a1_needs_json(
591    make_app: _MakeApp, tmp_path: Path
592) -> None:
593    """Under sphinx-needs' experimental ``needs_json_format_version = "2a1"`` the hops
594    are the record's ``source``, the code hop first."""
595    conf = CONF + 'needs_json_format_version = "2a1"\n'
596    app, _, _ = _build(make_app, _write(tmp_path, {**FILES, "docs/conf.py": conf}))
597    data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
598    record = next(r for r in data["needs"] if r["id"] == "IMPL_PY_ONE")
599    assert record["source"][0] == {
600        "kind": "code",
601        "physical": {
602            "root": "srcdir",
603            "path": "../src/py/shapes.py",
604            "start_line": 4,
605            "end_line": 4,
606        },
607        "semantic": [
608            {"kind": "class", "name": "Shape"},
609            {
610                "kind": "method",
611                "name": "area",
612                "extras": {"qualified_name": "Shape.area"},
613            },
614        ],
615        "language": "python",
616    }
617    assert [hop["kind"] for hop in record["source"]] == ["code", "page"]
618
619
620def test_a_need_after_rst_prolog_is_at_its_line_in_the_file(
621    make_app: _MakeApp, tmp_path: Path
622) -> None:
623    """``rst_prolog`` shifts the parser's count, not the file's lines."""
624    conf = CONF + 'rst_prolog = ".. |a| replace:: A\\n.. |b| replace:: B\\n"\n'
625    app, _, _ = _build(make_app, _write(tmp_path, {**FILES, "docs/conf.py": conf}))
626    needs = SphinxNeedsData(app.env).get_needs_view()
627    assert needs["IMPL_PY_ONE"]["lineno"] == 4
628    assert needs["IMPL_RS_TRAIT"]["lineno"] == 17
629    # the body's anchor stays in the parser's count, which the prolog shifts
630    source = needs["IMPL_PY_ONE"].source
631    assert source.lineno_content == source.parser_lineno == 7
632    assert _hops(app)["IMPL_RS_TRAIT"] == EXPECTED_HOPS["IMPL_RS_TRAIT"]
633
634
635def test_a_need_in_an_included_file_is_at_its_line_there(
636    make_app: _MakeApp, tmp_path: Path
637) -> None:
638    page = "Hops\n====\n\nProse.\n\n.. include:: traced.rst.txt\n"
639    included = "Some prose.\n\n.. src-trace::\n   :project: rust\n"
640    app, _, _ = _build(
641        make_app,
642        _write(
643            tmp_path,
644            {**FILES, "docs/index.rst": page, "docs/traced.rst.txt": included},
645        ),
646    )
647    needs = SphinxNeedsData(app.env).get_needs_view()
648    # line 3 of ``traced.rst.txt``, the included file, as a need directive records it
649    # (the parser's count, 10 before, was a line neither file has the directive on)
650    assert needs["IMPL_RS_TRAIT"]["lineno"] == 3
651    assert _hops(app)["IMPL_RS_TRAIT"] == [
652        "code srcdir:../src/rust/lib.rs:8-8 (rust) :: impl 'Widget' trait='Shape' > "
653        "method 'area' qualified_name='<Widget as Shape>::area'",
654        "file srcdir:traced.rst.txt:3-4 (.txt)",
655        "page [index] srcdir:index.rst (.rst) :: section 'Hops'",
656    ]
657
658
659# --- the code hop's path: Sphinx-Needs' rule, computed here ----------------------------
660
661_SRCDIR = os.path.join(os.sep, "project", "docs")
662
663#: ``(srcdir, path)``: what ``physical_for`` and the package's helper are both asked.
664PATH_SHAPES = {
665    "inside": (_SRCDIR, os.path.join(_SRCDIR, "code", "a.py")),
666    "outside": (_SRCDIR, os.path.join(os.sep, "project", "src", "a.py")),
667    "far outside": (_SRCDIR, os.path.join(os.sep, "elsewhere", "a.py")),
668    "relative to the working directory": ("docs", os.path.join("src", "a.py")),
669    "the source directory itself": (_SRCDIR, _SRCDIR),
670    "dotted segments": (_SRCDIR, os.path.join(_SRCDIR, "a", "..", "a.py")),
671}
672if sys.platform == "win32":
673    PATH_SHAPES["backslashes"] = ("C:\\project\\docs", "C:\\project\\src\\a.py")
674    PATH_SHAPES["forward slashes"] = ("C:/project/docs", "C:/project/docs/a.py")
675    PATH_SHAPES["another drive"] = ("C:\\project\\docs", "D:\\src\\a.py")
676
677
678@pytest.mark.parametrize("shape", list(PATH_SHAPES))
679def test_the_code_path_is_sphinx_needs_rule(shape: str) -> None:
680    from sphinx_codelinks.sphinx_extension.directives.src_trace import _code_physical
681    from sphinx_needs._need_source import physical_for
682
683    srcdir, path = PATH_SHAPES[shape]
684    assert _code_physical(srcdir, path) == physical_for(srcdir, path)
685    assert _code_physical(srcdir, path, 3, 7) == physical_for(srcdir, path, 3, 7)
686
687
688def test_the_code_path_under_a_symlinked_directory_is_sphinx_needs_rule(
689    tmp_path: Path,
690) -> None:
691    from sphinx_codelinks.sphinx_extension.directives.src_trace import _code_physical
692    from sphinx_needs._need_source import physical_for
693
694    (tmp_path / "real").mkdir()
695    (tmp_path / "real" / "a.py").write_text(INSIDE, encoding="utf-8")
696    (tmp_path / "docs").mkdir()
697    try:
698        (tmp_path / "docs" / "linked").symlink_to(
699            tmp_path / "real", target_is_directory=True
700        )
701    except OSError:
702        pytest.skip("no symlinks here")
703    for srcdir, path in [
704        (tmp_path / "docs", tmp_path / "docs" / "linked" / "a.py"),
705        (tmp_path / "docs" / "linked", tmp_path / "real" / "a.py"),
706    ]:
707        assert _code_physical(srcdir, path) == physical_for(srcdir, path)
708
709
710# --- incremental builds: each equals a fresh build -------------------------------------
711
712#: a page whose need the traced one links to: it shows the traced need as a back link
713OTHER = """\
714Other
715=====
716
717.. req:: A requirement
718   :id: REQ_1
719"""
720
721PY_LINKED = PY.replace(
722    "# @Area of a shape, IMPL_PY_ONE, impl",
723    "# @Area of a shape, IMPL_PY_ONE, impl, [REQ_1]",
724)
725INCREMENTAL = {
726    **FILES,
727    "docs/index.rst": INDEX + "\n.. toctree::\n\n   other\n",
728    "docs/other.rst": OTHER,
729    "src/py/shapes.py": PY_LINKED,
730}
731
732#: ``scenario: {file: new content}``, over :data:`INCREMENTAL`
733SCENARIOS: dict[str, dict[str, str | bytes | None]] = {
734    # three lines above every marker of the file: the needs only move
735    "marker-moved": {"src/py/shapes.py": "\n\n\n" + PY_LINKED},
736    # the entity a marker is attached to is renamed: its hop's semantic changes
737    "entity-renamed": {
738        "src/py/shapes.py": PY_LINKED.replace("def area", "def surface")
739    },
740    # the marker itself is edited
741    "source-edited": {
742        "src/py/shapes.py": PY_LINKED.replace("Area of a shape", "Area of any shape")
743    },
744}
745
746
747def _pages(outdir: Path) -> dict[str, str]:
748    """The body of every page, the part a need can show up in."""
749    body = re.compile(
750        r'<div class="body" role="main">(.*?)<div class="sphinxsidebar"', re.DOTALL
751    )
752    pages = {}
753    for path in sorted(outdir.glob("*.html")):
754        if path.stem in {"genindex", "search", "permalink"}:
755            continue
756        match = body.search(path.read_text(encoding="utf-8"))
757        assert match is not None, path
758        pages[path.stem] = re.sub(r"SNCB-[0-9a-f]+", "SNCB-", match.group(1))
759    return pages
760
761
762def _sources(app: SphinxTestApp) -> dict[str, Any]:
763    """Every need's lineno and hops (paths are relative, so two projects compare)."""
764    return {
765        need_id: (need["lineno"], need.source.hops)
766        for need_id, need in SphinxNeedsData(app.env).get_needs_view().items()
767    }
768
769
770def _needs_json(app: SphinxTestApp) -> dict[str, Any]:
771    data = json.loads(Path(app.outdir, "needs.json").read_text(encoding="utf-8"))
772    return data["versions"][data["current_version"]]["needs"]
773
774
775def _assert_equivalent(
776    make_app: _MakeApp, tmp_path: Path, scenario: str
777) -> tuple[SphinxTestApp, list[str]]:
778    """Build, edit, build incrementally, and compare with a fresh build of the edit.
779
780    :return: The incremental app, and the documents it wrote.
781    """
782    after = {**INCREMENTAL, **SCENARIOS[scenario]}
783    incremental = _write(tmp_path / "incremental", INCREMENTAL)
784    _build(make_app, incremental)
785    _write(tmp_path / "incremental", after)
786    app, _, written = _build(make_app, incremental, fresh=False)
787
788    fresh_app, _, _ = _build(make_app, _write(tmp_path / "fresh", after))
789    got, expected = _pages(Path(app.outdir)), _pages(Path(fresh_app.outdir))
790    stale = sorted(name for name in expected if got.get(name) != expected[name])
791    assert stale == [], f"pages not re-written: {stale} (wrote {written})"
792    assert _needs_json(app) == _needs_json(fresh_app)
793    assert _sources(app) == _sources(fresh_app)
794    return app, written
795
796
797def test_a_marker_that_moves_rewrites_only_its_own_page(
798    make_app: _MakeApp, tmp_path: Path
799) -> None:
800    """Its needs only moved: the page showing one as a back link is not re-written."""
801    app, written = _assert_equivalent(make_app, tmp_path, "marker-moved")
802    assert written == ["index"]
803    assert _hops(app)["IMPL_PY_ONE"][0].startswith(
804        "code srcdir:../src/py/shapes.py:7-7 "
805    )
806
807
808def test_a_renamed_entity_rewrites_the_pages_showing_its_need(
809    make_app: _MakeApp, tmp_path: Path
810) -> None:
811    app, written = _assert_equivalent(make_app, tmp_path, "entity-renamed")
812    assert sorted(written) == ["index", "other"]
813    assert _hops(app)["IMPL_PY_ONE"][0].endswith(
814        ":: class 'Shape' > method 'surface' qualified_name='Shape.surface'"
815    )
816
817
818def test_an_edited_source_file_equals_a_fresh_build(
819    make_app: _MakeApp, tmp_path: Path
820) -> None:
821    _, written = _assert_equivalent(make_app, tmp_path, "source-edited")
822    assert sorted(written) == ["index", "other"]
823
824
825# --- the upgrade ------------------------------------------------------------------------
826
827
828def test_an_environment_pickled_at_env_version_1_is_read_again(
829    make_app: _MakeApp, tmp_path: Path
830) -> None:
831    """Version 1 recorded each need on its page alone, at the parser's line, with no
832    code hop. Nothing in the sources or the configuration changed: ``env_version`` 2 is
833    what has the build read the page again here, rather than keep those records."""
834    srcdir = _write(tmp_path, FILES)
835    app, _, _ = _build(make_app, srcdir)
836    pickled = Path(app.doctreedir, "environment.pickle")
837    env = pickle.loads(pickled.read_bytes())
838    env.version["sphinx_codelinks"] = 1
839    # what version 1 recorded: the page, at the directive's line, and nothing else
840    for need in env._needs_all_needs.values():
841        need._source = NeedItemSource.on_page(need["docname"], lineno=need["lineno"])
842    pickled.write_bytes(pickle.dumps(env, pickle.HIGHEST_PROTOCOL))
843
844    app, read, _ = _build(make_app, srcdir, fresh=False)
845    assert read == ["index"]
846    assert _hops(app) == EXPECTED_HOPS
847
848
849def test_an_environment_pickled_at_env_version_2_is_read_again(
850    make_app: _MakeApp, tmp_path: Path
851) -> None:
852    """Version 2 recorded each need's code hop without the entity's qualified name.
853    Nothing in the sources or the configuration changed: ``env_version`` 3 is what has
854    the build read the page again here, rather than keep those records."""
855    srcdir = _write(tmp_path, FILES)
856    app, _, _ = _build(make_app, srcdir)
857    pickled = Path(app.doctreedir, "environment.pickle")
858    env = pickle.loads(pickled.read_bytes())
859    env.version["sphinx_codelinks"] = 2
860    # what version 2 recorded: the code hop's entries without the qualified name
861    for need in env._needs_all_needs.values():
862        code, *rest = need._source.hops
863        entries = tuple(
864            dataclasses.replace(
865                entry,
866                extras=tuple(e for e in entry.extras if e[0] != "qualified_name"),
867            )
868            for entry in code.semantic or ()
869        )
870        hops = (
871            dataclasses.replace(code, semantic=entries if code.semantic else None),
872            *rest,
873        )
874        object.__setattr__(need._source, "hops", hops)
875    pickled.write_bytes(pickle.dumps(env, pickle.HIGHEST_PROTOCOL))
876
877    app, read, _ = _build(make_app, srcdir, fresh=False)
878    assert read == ["index"]
879    assert _hops(app) == EXPECTED_HOPS
880
881
882def test_an_entity_s_qualified_name_follows_its_trait(tmp_path: Path) -> None:
883    """A marker attached to a Rust trait impl itself: the entity's extras are its
884    ``trait`` and then its ``qualified_name``, in that order (extras are compared in
885    order)."""
886    from types import SimpleNamespace
887
888    from sphinx_codelinks.source_discover.config import CommentType
889    from sphinx_codelinks.sphinx_extension.directives.src_trace import code_hop
890
891    marker = SimpleNamespace(
892        source_map={"start": {"row": 6}, "end": {"row": 6}},
893        scope={
894            "language": "rust",
895            "qualified_name": "<Widget as Shape>",
896            "semantic": [{"kind": "impl", "name": "Widget", "trait": "Shape"}],
897        },
898    )
899    hop = code_hop(
900        marker,  # ty: ignore[invalid-argument-type]
901        tmp_path / "lib.rs",
902        tmp_path,
903        CommentType.rust,
904    )
905    (impl,) = hop.semantic or ()
906    assert impl.extras == (("trait", "Shape"), ("qualified_name", "<Widget as Shape>"))