[docs] 1# @Test suite for source code analysis and marker extraction, TEST_ANA_1, test, [IMPL_LNK_1, IMPL_ONE_1, IMPL_MLN_1]
2import json
3from pathlib import Path
4
5import pytest
6
7from sphinx_codelinks.analyse.analyse import SourceAnalyse, _count
8from sphinx_codelinks.config import SourceAnalyseConfig
9from sphinx_codelinks.source_discover.config import CommentType
10from tests.conftest import (
11 ONELINE_COMMENT_STYLE,
12 ONELINE_COMMENT_STYLE_DEFAULT,
13 TEST_DIR,
14)
15
16TEST_DATA_DIR = Path(__file__).parent.parent / "tests" / "data"
17
18
19@pytest.mark.parametrize(
20 ("src_dir", "src_paths"),
21 [
22 (
23 TEST_DATA_DIR,
24 [
25 TEST_DATA_DIR / "oneline_comment_default" / "default_oneliners.c",
26 TEST_DATA_DIR / "need_id_refs" / "dummy_1.cpp",
27 TEST_DATA_DIR / "multiline_needs" / "dummy_1.cpp",
28 ],
29 )
30 ],
31)
32def test_analyse(src_dir, src_paths, tmp_path, snapshot_marks):
33 src_analyse_config = SourceAnalyseConfig(
34 src_files=src_paths,
35 src_dir=src_dir,
36 get_need_id_refs=True,
37 get_oneline_needs=True,
38 get_multiline_needs=True,
39 )
40
41 analyse = SourceAnalyse(src_analyse_config)
42 analyse.git_remote_url = None
43 analyse.git_commit_rev = None
44 analyse.run()
45 analyse.dump_marked_content(tmp_path)
46
47 dumped_content = tmp_path / "marked_content.json"
48 with dumped_content.open("r") as f:
49 marked_content = json.load(f)
50 # normalize filepath
51 for obj in marked_content:
52 obj["filepath"] = (
53 Path(obj["filepath"]).relative_to(src_analyse_config.src_dir)
54 ).as_posix()
55 if "source" in obj:
56 # a multi-line need's path is relative to the git root when the checkout is
57 # a repository, else to src_dir: pin it against src_dir, as filepath is
58 assert obj["source"].pop("root") in ("git", "src_dir")
59 assert obj["source"]["path"].endswith(obj["filepath"])
60 obj["source"]["path"] = obj["filepath"]
61 assert marked_content == snapshot_marks
62
63
64@pytest.mark.parametrize(
65 "case",
66 [
67 {
68 "src_dir": TEST_DIR / "data" / "dcdc",
69 "src_paths": [
70 TEST_DIR / "data" / "dcdc" / "charge" / "demo_1.cpp",
71 TEST_DIR / "data" / "dcdc" / "charge" / "demo_2.cpp",
72 TEST_DIR / "data" / "dcdc" / "discharge" / "demo_3.cpp",
73 TEST_DIR / "data" / "dcdc" / "supercharge.cpp",
74 ],
75 "comment_type": CommentType.cpp,
76 "oneline_comment_style": ONELINE_COMMENT_STYLE,
77 "result": {
78 "num_src_files": 4,
79 "num_uncached_files": 4,
80 "num_cached_files": 0,
81 "num_comments": 29,
82 "num_oneline_warnings": 0,
83 "num_oneline_needs": 12,
84 },
85 },
86 {
87 "src_dir": TEST_DIR / "data" / "oneline_comment_basic",
88 "src_paths": [
89 TEST_DIR / "data" / "oneline_comment_basic" / "basic_oneliners.c",
90 ],
91 "comment_type": CommentType.cpp,
92 "oneline_comment_style": ONELINE_COMMENT_STYLE,
93 "result": {
94 "num_src_files": 1,
95 "num_uncached_files": 1,
96 "num_cached_files": 0,
97 "num_comments": 14,
98 "num_oneline_warnings": 0,
99 "num_oneline_needs": 8,
100 },
101 },
102 {
103 "src_dir": TEST_DIR / "data" / "oneline_comment_default",
104 "src_paths": [
105 TEST_DIR / "data" / "oneline_comment_default" / "default_oneliners.c",
106 ],
107 "comment_type": CommentType.cpp,
108 "oneline_comment_style": ONELINE_COMMENT_STYLE_DEFAULT,
109 "result": {
110 "num_src_files": 1,
111 "num_uncached_files": 1,
112 "num_cached_files": 0,
113 "num_comments": 5,
114 "num_oneline_warnings": 1,
115 "num_oneline_needs": 4,
116 },
117 },
118 {
119 "src_dir": TEST_DIR / "data" / "rust",
120 "src_paths": [
121 TEST_DIR / "data" / "rust" / "demo.rs",
122 ],
123 "comment_type": CommentType.rust,
124 "oneline_comment_style": ONELINE_COMMENT_STYLE_DEFAULT,
125 "result": {
126 "num_src_files": 1,
127 "num_uncached_files": 1,
128 "num_cached_files": 0,
129 "num_comments": 6,
130 "num_oneline_warnings": 0,
131 "num_oneline_needs": 4,
132 },
133 },
134 {
135 "src_dir": TEST_DIR / "data" / "typescript",
136 "src_paths": [
137 TEST_DIR / "data" / "typescript" / "demo.ts",
138 ],
139 "comment_type": CommentType.ts,
140 "oneline_comment_style": ONELINE_COMMENT_STYLE_DEFAULT,
141 "result": {
142 "num_src_files": 1,
143 "num_uncached_files": 1,
144 "num_cached_files": 0,
145 "num_comments": 4,
146 "num_oneline_warnings": 0,
147 "num_oneline_needs": 1,
148 },
149 },
150 {
151 "src_dir": TEST_DIR / "data" / "typescript",
152 "src_paths": [
153 TEST_DIR / "data" / "typescript" / "demo.tsx",
154 ],
155 "comment_type": CommentType.ts,
156 "oneline_comment_style": ONELINE_COMMENT_STYLE_DEFAULT,
157 "result": {
158 "num_src_files": 1,
159 "num_uncached_files": 1,
160 "num_cached_files": 0,
161 "num_comments": 1,
162 "num_oneline_warnings": 0,
163 "num_oneline_needs": 1,
164 },
165 },
166 {
167 "src_dir": TEST_DIR / "data" / "jsonc",
168 "src_paths": [
169 TEST_DIR / "data" / "jsonc" / "demo.jsonc",
170 ],
171 "comment_type": CommentType.jsonc,
172 "oneline_comment_style": ONELINE_COMMENT_STYLE_DEFAULT,
173 "result": {
174 "num_src_files": 1,
175 "num_uncached_files": 1,
176 "num_cached_files": 0,
177 "num_comments": 4,
178 "num_oneline_warnings": 0,
179 "num_oneline_needs": 3,
180 },
181 },
182 ],
183)
184def test_analyse_oneline_needs(tmp_path, case):
185 src_analyse_config = SourceAnalyseConfig(
186 src_files=case["src_paths"],
187 src_dir=case["src_dir"],
188 get_need_id_refs=False,
189 get_oneline_needs=True,
190 get_multiline_needs=False,
191 oneline_comment_style=case["oneline_comment_style"],
192 comment_type=case["comment_type"],
193 )
194 src_analyse = SourceAnalyse(src_analyse_config)
195 src_analyse.run()
196
197 result = case["result"]
198 assert len(src_analyse.src_files) == result["num_src_files"]
199 assert len(src_analyse.warnings) == result["num_oneline_warnings"]
200 assert len(src_analyse.oneline_needs) == result["num_oneline_needs"]
201
202 cnt_comments = 0
203 for src_file in src_analyse.src_files:
204 cnt_comments += len(src_file.src_comments)
205 assert cnt_comments == result["num_comments"]
206
207
208def test_explicit_git_root_configuration(tmp_path):
209 """Test that explicit git_root configuration is used instead of auto-detection."""
210 # Create a fake git repo structure in tmp_path
211 fake_git_root = tmp_path / "fake_repo"
212 fake_git_root.mkdir()
213 (fake_git_root / ".git").mkdir()
214
215 # Create a minimal .git/config with remote URL
216 git_config = fake_git_root / ".git" / "config"
217 git_config.write_text(
218 '[remote "origin"]\n url = https://github.com/test/repo.git\n'
219 )
220
221 # Create HEAD file pointing to a branch ref
222 git_head = fake_git_root / ".git" / "HEAD"
223 git_head.write_text("ref: refs/heads/main\n")
224
225 # Create the refs/heads/main file with the commit hash
226 refs_dir = fake_git_root / ".git" / "refs" / "heads"
227 refs_dir.mkdir(parents=True)
228 (refs_dir / "main").write_text("abc123def456\n")
229
230 # Create source file in a deeply nested location
231 src_dir = tmp_path / "deeply" / "nested" / "src"
232 src_dir.mkdir(parents=True)
233 src_file = src_dir / "test.c"
234 src_file.write_text("// @Test, TEST_1\nvoid test() {}\n")
235
236 # Configure with explicit git_root
237 src_analyse_config = SourceAnalyseConfig(
238 src_files=[src_file],
239 src_dir=src_dir,
240 get_need_id_refs=False,
241 get_oneline_needs=True,
242 get_multiline_needs=False,
243 git_root=fake_git_root,
244 )
245
246 src_analyse = SourceAnalyse(src_analyse_config)
247
248 # Verify the explicit git_root was used
249 assert src_analyse.git_root == fake_git_root.resolve()
250 assert src_analyse.git_remote_url == "https://github.com/test/repo.git"
251 assert src_analyse.git_commit_rev == "abc123def456"
252
253
254def test_git_root_auto_detection_when_not_configured(tmp_path):
255 """Test that git_root is auto-detected when not explicitly configured."""
256 src_dir = TEST_DIR / "data" / "dcdc"
257 src_paths = [src_dir / "charge" / "demo_1.cpp"]
258
259 # Don't set git_root - it should auto-detect
260 src_analyse_config = SourceAnalyseConfig(
261 src_files=src_paths,
262 src_dir=src_dir,
263 get_need_id_refs=False,
264 get_oneline_needs=True,
265 get_multiline_needs=False,
266 # git_root is not set, so auto-detection should be used
267 )
268
269 src_analyse = SourceAnalyse(src_analyse_config)
270
271 # The test is running inside a git repo, so git_root should be detected
272 # We just verify it's not None (since this test runs in the sphinx-codelinks repo)
273 assert src_analyse.git_root is not None
274 assert (src_analyse.git_root / ".git").exists()
275
276
277def test_oneline_parser_warnings_are_collected(tmp_path):
278 """Test that oneline parser warnings are collected for later output."""
279 src_dir = TEST_DIR / "data" / "oneline_comment_default"
280 src_paths = [src_dir / "default_oneliners.c"]
281 src_analyse_config = SourceAnalyseConfig(
282 src_files=src_paths,
283 src_dir=src_dir,
284 get_need_id_refs=False,
285 get_oneline_needs=True,
286 get_multiline_needs=False,
287 oneline_comment_style=ONELINE_COMMENT_STYLE_DEFAULT,
288 )
289 src_analyse = SourceAnalyse(src_analyse_config)
290 src_analyse.run()
291
292 # Verify that warnings were collected
293 assert len(src_analyse.warnings) == 1
294 warning = src_analyse.warnings[0]
295 assert "too_many_fields" in warning.sub_type
296 assert warning.lineno == 17
297 # the old name reads the same list, for one release
298 assert src_analyse.oneline_warnings is src_analyse.warnings
299
300
301def test_a_hash_comment_is_never_a_docstring_tag(tmp_path: Path) -> None:
302 """The ``docstring_tag`` rule reads Python docstrings only: a field-shaped tag line
303 in a ``#`` comment goes to the one-line parser, as in any other language."""
304 src = tmp_path / "x.py"
305 src.write_text(
306 "# @param a: the first, thing\ndef f(a):\n pass\n", encoding="utf-8"
307 )
308 src_analyse = SourceAnalyse(
309 SourceAnalyseConfig(
310 src_files=[src],
311 src_dir=tmp_path,
312 comment_type=CommentType.python,
313 get_need_id_refs=False,
314 get_oneline_needs=True,
315 get_multiline_needs=False,
316 oneline_comment_style=ONELINE_COMMENT_STYLE_DEFAULT,
317 )
318 )
319 src_analyse.run()
320
321 assert src_analyse.warnings == []
322 assert [need.need["id"] for need in src_analyse.oneline_needs] == ["thing"]
323
324
325@pytest.mark.parametrize("ts_first", [True, False], ids=["ts-first", "tsx-first"])
326def test_a_ts_and_a_tsx_file_in_one_run_each_get_their_grammar(
327 tmp_path: Path, ts_first: bool
328) -> None:
329 """One analysis of a ``.ts`` and a ``.tsx`` file parses each with its own
330 grammar, whichever comes first: the ``.ts`` marker after a ``<T>`` cast is kept
331 (the TypeScript grammar), and the ``//`` in the ``.tsx`` file's JSX text is text,
332 not a marker (the TSX grammar)."""
333 ts_src = tmp_path / "a.ts"
334 ts_src.write_text(
335 "// @Before cast, IMPL_BEFORE, impl\n"
336 "const v = <string>x;\n"
337 "// @After cast, IMPL_AFTER, impl\n"
338 "function g(): void {}\n",
339 encoding="utf-8",
340 )
341 tsx_src = tmp_path / "b.tsx"
342 tsx_src.write_text(
343 "// @Component, IMPL_COMPONENT, impl\n"
344 "export const A = () => <p>// @Fake, FAKE_ID, impl</p>;\n",
345 encoding="utf-8",
346 )
347 src_analyse = SourceAnalyse(
348 SourceAnalyseConfig(
349 src_files=[ts_src, tsx_src] if ts_first else [tsx_src, ts_src],
350 src_dir=tmp_path,
351 comment_type=CommentType.ts,
352 get_need_id_refs=False,
353 get_oneline_needs=True,
354 get_multiline_needs=False,
355 oneline_comment_style=ONELINE_COMMENT_STYLE_DEFAULT,
356 )
357 )
358 src_analyse.run()
359
360 assert sorted(need.need["id"] for need in src_analyse.oneline_needs) == [
361 "IMPL_AFTER",
362 "IMPL_BEFORE",
363 "IMPL_COMPONENT",
364 ]
365
366
367def test_count_pluralizes_nouns() -> None:
368 assert _count(0, "file") == "0 files"
369 assert _count(1, "file") == "1 file"
370 assert _count(2, "marker") == "2 markers"
371 assert _count(1, "multi-line need") == "1 multi-line need"