[docs]  1# @Test suite for the need id reference record, TEST_NEED_ID_REF_1, test, [IMPL_LNK_1]
  2"""The :class:`NeedIdRef` record: plain data that survives JSON unchanged."""
  3
  4import json
  5from pathlib import Path
  6
  7import pytest
  8
  9from sphinx_codelinks.analyse.analyse import SourceAnalyse
 10from sphinx_codelinks.analyse.references import NeedIdRef, need_id_ref_records
 11from sphinx_codelinks.config import OneLineCommentStyle, SourceAnalyseConfig
 12
 13FULL = NeedIdRef(
 14    need_id="A::B::C.d",
 15    project="src",
 16    marker="@need-ids:",
 17    path="src/refs.cpp",
 18    root="src_dir",
 19    lineno=4,
 20    start_column=14,
 21    end_column=31,
 22    scope="int helper(int a) { return a + 1; }",
 23    scope_rows=(5, 5),
 24    remote_url="https://github.com/o/r/blob/" + "a" * 40 + "/src/refs.cpp#L4",
 25    local_url="src/refs.cpp#L4",
 26)
 27
 28
 29@pytest.mark.parametrize(
 30    "record",
 31    [
 32        pytest.param(FULL, id="every-field"),
 33        pytest.param(
 34            NeedIdRef(
 35                need_id="REQ_1",
 36                project="p",
 37                marker="@need-ids:",
 38                path="a.py",
 39                lineno=1,
 40                start_column=12,
 41                end_column=17,
 42            ),
 43            id="optional-fields-unset",
 44        ),
 45    ],
 46)
 47def test_record_round_trips_through_json_byte_for_byte(record: NeedIdRef) -> None:
 48    """``to_dict`` -> ``json`` -> ``from_dict`` -> ``to_dict`` -> ``json`` is the
 49    identity, and gives back an equal record: nothing in it is not data."""
 50    text = json.dumps(record.to_dict(), sort_keys=True)
 51    again = NeedIdRef.from_dict(json.loads(text))
 52    assert again == record
 53    assert json.dumps(again.to_dict(), sort_keys=True) == text
 54    assert set(json.loads(text)) == {
 55        "need_id",
 56        "project",
 57        "marker",
 58        "path",
 59        "root",
 60        "lineno",
 61        "start_column",
 62        "end_column",
 63        "scope",
 64        "scope_rows",
 65        "remote_url",
 66        "local_url",
 67    }
 68
 69
 70def test_record_ignores_unknown_keys() -> None:
 71    """A newer producer's key (slice 2 plans ``relation``) does not break a reader."""
 72    data = FULL.to_dict() | {"relation": "implements"}
 73    assert NeedIdRef.from_dict(data) == FULL
 74
 75
 76@pytest.mark.parametrize(
 77    ("key", "value"),
 78    [
 79        pytest.param("path", "/etc/passwd", id="absolute-path"),
 80        pytest.param("lineno", "3", id="lineno-as-string"),
 81        pytest.param("lineno", True, id="lineno-as-bool"),
 82        pytest.param("path", "C:/src/a.cpp", id="drive"),
 83        pytest.param("path", "C:\\src\\a.cpp", id="windows-drive-path"),
 84        pytest.param("path", "../outside.cpp", id="dotdot"),
 85        pytest.param("path", "src/../x.cpp", id="dotdot-inside"),
 86        pytest.param("need_id", 5, id="int-id"),
 87        pytest.param("need_id", "", id="empty-id"),
 88        pytest.param("root", "home", id="unknown-root"),
 89    ],
 90)
 91def test_record_checks_its_invariants(key: str, value: object) -> None:
 92    data = FULL.to_dict() | {key: value}
 93    with pytest.raises(ValueError, match=repr(key)):
 94        NeedIdRef.from_dict(data)
 95
 96
 97@pytest.mark.parametrize("path", ["a:b.cpp", "we\\ird.cpp", "src\\a.cpp", "x..y.cpp"])
 98def test_record_accepts_legal_posix_names(path: str) -> None:
 99    """``:`` and ``\\`` are legal POSIX name characters: what the producer can emit for
100    such a file reads back."""
101    record = FULL.to_dict() | {"path": path}
102    assert NeedIdRef.from_dict(json.loads(json.dumps(record))).path == path
103
104
105def test_records_from_an_analysis(tmp_path: Path) -> None:
106    """One record per id; the ids pass through untouched (``::`` and ``.`` included);
107    the path is relative and POSIX; the line, the columns and the scope are kept."""
108    src = tmp_path / "src"
109    src.mkdir()
110    source = src / "refs.cpp"
111    source.write_text(
112        "// @need-ids: A::B::C.d, REQ_1\nvoid implements_a() {}\n", encoding="utf-8"
113    )
114    analyse = SourceAnalyse(
115        SourceAnalyseConfig(src_files=[source], src_dir=src, git_root=tmp_path)
116    )
117    analyse.run()
118
119    records = need_id_ref_records(
120        analyse.need_id_refs,
121        project="src",
122        root=tmp_path,
123        remote_url=lambda path, line: f"remote:{path.name}:{line}",
124        local_url=lambda path, line: f"local:{path.name}:{line}",
125    )
126
127    assert [record.need_id for record in records] == ["A::B::C.d", "REQ_1"]
128    first = records[0]
129    assert first.project == "src"
130    assert first.marker == "@need-ids:"
131    assert first.path == "src/refs.cpp"
132    assert first.lineno == 1
133    assert (first.start_column, first.end_column) == (13, 29)
134    assert first.scope == "void implements_a() {}"
135    assert first.scope_rows == (2, 2)
136    assert first.root == "git"
137    assert first.remote_url == "remote:refs.cpp:1"
138    assert first.local_url == "local:refs.cpp:1"
139    assert NeedIdRef.from_dict(json.loads(json.dumps(first.to_dict()))) == first
140
141
142def test_a_need_ids_comment_is_never_a_one_line_need(tmp_path: Path) -> None:
143    """On the default one-line style (start sequence ``@``) a comment starting with
144    the ``@need-ids:`` marker is a reference, never a need (ubCode's precedence); a
145    real one-line need beside it still parses."""
146    src = tmp_path / "src"
147    src.mkdir()
148    source = src / "refs.cpp"
149    source.write_text(
150        "// @need-ids: REQ_001, IMPL_X\n"
151        "void a() {}\n"
152        "// @A real one-line need, IMPL_REAL\n"
153        "void b() {}\n",
154        encoding="utf-8",
155    )
156    analyse = SourceAnalyse(
157        SourceAnalyseConfig(
158            src_files=[source], src_dir=src, git_root=tmp_path, get_oneline_needs=True
159        )
160    )
161    analyse.run()
162
163    refs = need_id_ref_records(analyse.need_id_refs, project="src", root=tmp_path)
164    assert [ref.need_id for ref in refs] == ["REQ_001", "IMPL_X"]
165    assert [need.need["id"] for need in analyse.oneline_needs] == ["IMPL_REAL"]
166
167
168def test_scope_is_the_first_line_only(tmp_path: Path) -> None:
169    """A 100-line function: ``scope`` is its signature, ``scope_rows`` spans it all."""
170    src = tmp_path / "src"
171    src.mkdir()
172    body = "".join(f"    x += {n};\n" for n in range(98))
173    source = src / "big.cpp"
174    source.write_text(
175        f"// @need-ids: REQ_1\nint big(int x) {{   \n{body}}}\n", encoding="utf-8"
176    )
177    analyse = SourceAnalyse(
178        SourceAnalyseConfig(src_files=[source], src_dir=src, git_root=tmp_path)
179    )
180    analyse.run()
181
182    (record,) = need_id_ref_records(analyse.need_id_refs, project="p", root=tmp_path)
183    assert record.scope == "int big(int x) {"
184    assert record.scope_rows == (2, 101)
185
186
187def _analyse(tmp_path: Path, text: str, **config: object) -> SourceAnalyse:
188    src = tmp_path / "src"
189    src.mkdir(exist_ok=True)
190    source = src / "mixed.cpp"
191    source.write_text(text, encoding="utf-8")
192    analyse = SourceAnalyse(
193        SourceAnalyseConfig(
194            src_files=[source],
195            src_dir=src,
196            git_root=tmp_path,
197            get_oneline_needs=True,
198            **config,  # ty: ignore[invalid-argument-type]
199        )
200    )
201    analyse.run()
202    return analyse
203
204
205def test_a_need_and_a_marker_in_one_comment_are_both_kept(tmp_path: Path) -> None:
206    """Only a line that STARTS with the marker is withheld from the one-line parser: a
207    one-line need ahead of a marker on the same line keeps its need and gives the
208    reference; in one block comment, a need line and a marker line keep theirs, on
209    their own rows."""
210    bracketed = _analyse(
211        tmp_path,
212        "// [[impl one, IMPL_001]] @need-ids: REQ_001\nvoid a() {}\n",
213        oneline_comment_style=OneLineCommentStyle(
214            start_sequence="[[", end_sequence="]]"
215        ),
216    )
217    assert [need.need["id"] for need in bracketed.oneline_needs] == ["IMPL_001"]
218    assert [ref.need_ids for ref in bracketed.need_id_refs] == [["REQ_001"]]
219
220    block = _analyse(
221        tmp_path,
222        "// @need-ids: REQ_001, IMPL_X\n"
223        "void a() {}\n"
224        "// @Real need, IMPL_REAL\n"
225        "void b() {}\n"
226        "/*\n"
227        " * @need-ids: REQ_002, IMPL_Y\n"
228        " * @Second real, IMPL_TWO\n"
229        " */\n"
230        "void c() {}\n"
231        "// see @need-ids: REQ_003, IMPL_Z\n"
232        "/*\n"
233        " * @Third real, IMPL_THREE\n"
234        " * @need-ids: REQ_004\n"
235        " */\n"
236        "void d() {}\n",
237    )
238    needs = {
239        need.need["id"]: need.source_map["start"]["row"] + 1
240        for need in block.oneline_needs
241    }
242    assert needs == {"IMPL_REAL": 3, "IMPL_TWO": 7, "IMPL_THREE": 12}
243    refs = {
244        tuple(ref.need_ids): ref.source_map["start"]["row"] + 1
245        for ref in block.need_id_refs
246    }
247    assert refs == {
248        ("REQ_001", "IMPL_X"): 1,
249        ("REQ_002", "IMPL_Y"): 6,
250        ("REQ_003", "IMPL_Z"): 10,
251        ("REQ_004",): 13,
252    }
253
254
255def test_only_a_line_starting_with_the_marker_is_a_reference_line(
256    tmp_path: Path,
257) -> None:
258    """Pins the current rule: decoration and whitespace may precede the marker; a word
259    may not (``// see @need-ids: X`` is not withheld from the one-line parser -- which
260    ignores it anyway -- and its references are still extracted, as above)."""
261    analyse = _analyse(tmp_path, "// nothing\n")
262    assert analyse._is_need_id_refs_line("// @need-ids: X")
263    assert analyse._is_need_id_refs_line(" * @need-ids: X")
264    assert analyse._is_need_id_refs_line("# @need-ids: X")
265    assert not analyse._is_need_id_refs_line("// see @need-ids: X")
266    assert not analyse._is_need_id_refs_line("// [[a, B]] @need-ids: X")