[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")