[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"
372
373
374def test_dump_marked_content_when_its_directory_appears_after_the_check(
375 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
376) -> None:
377 """Another process may create the output directory between a check for it and the
378 ``mkdir``: the dump must not fail with ``FileExistsError`` then."""
379 outdir = tmp_path / "out"
380 real_exists = Path.exists
381
382 def exists_then_created_elsewhere(self: Path, *args, **kwargs) -> bool:
383 found = real_exists(self, *args, **kwargs)
384 if self == outdir and not found:
385 outdir.mkdir() # the other process wins the race
386 return found
387
388 analyse = SourceAnalyse(SourceAnalyseConfig(src_files=[], src_dir=tmp_path))
389 monkeypatch.setattr(Path, "exists", exists_then_created_elsewhere)
390 analyse.dump_marked_content(outdir)
391
392 marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
393 assert marked == []
394
395
396def test_a_bom_before_a_powershell_marker_keeps_the_title_clean(tmp_path: Path):
397 """Windows PowerShell 5.1 saves a script with a UTF-8 BOM: the marker on the
398 first line is still a need on that line, and the BOM is not part of its title."""
399 source = tmp_path / "demo.ps1"
400 source.write_bytes("# @Bom Title, IMPL_BOM, impl\nfunction f { }\n".encode())
401 config = SourceAnalyseConfig(
402 src_files=[source],
403 src_dir=tmp_path,
404 get_oneline_needs=True,
405 comment_type=CommentType.powershell,
406 )
407 analyse = SourceAnalyse(config)
408 analyse.git_remote_url = None
409 analyse.git_commit_rev = None
410 analyse.run()
411
412 assert len(analyse.oneline_needs) == 1
413 need = analyse.oneline_needs[0]
414 assert need.need["title"] == "Bom Title"
415 assert need.need["id"] == "IMPL_BOM"
416 assert need.source_map["start"]["row"] == 0
417
418
419def _oneline_run(
420 tmp_path: Path, name: str, source: str, comment_type: CommentType
421) -> SourceAnalyse:
422 """One file analysed with the default one-line style, outside a repository."""
423 path = tmp_path / name
424 path.write_bytes(source.encode())
425 analyse = SourceAnalyse(
426 SourceAnalyseConfig(
427 src_files=[path],
428 src_dir=tmp_path,
429 get_oneline_needs=True,
430 comment_type=comment_type,
431 )
432 )
433 analyse.git_remote_url = None
434 analyse.git_commit_rev = None
435 analyse.run()
436 return analyse
437
438
439POWERSHELL_LITERALS = """\
440# @{ Keys = 'k' }, $Value.Keys is the string 'k' and not an array
441# @() or $null is provided and allowed, do nothing
442# @($null) which evaluates to false, even though, it is
443# `@(9, 9, 1) -eq 1` returns 1, not a list
444<#
445.EXAMPLE
446 @{ Name = 'Jakub'; Age = 30 } | Should-BeHashtable -Key Name, Age
447 @(1, 2, 3) | Should-BeHashtable
448#>
449# @Real, IMPL_PS_REAL, impl
450function Start-Demo { }
451"""
452
453
454def test_a_powershell_hashtable_or_array_is_no_marker(tmp_path: Path) -> None:
455 """PowerShell's ``@`` is the sigil of a hashtable ``@{ }`` and an array ``@( )``,
456 which commented-out code and ``.EXAMPLE`` help hold at the start of a line: the
457 start sequence directly followed by ``{`` or ``(`` opens no marker, and no
458 warning."""
459 analyse = _oneline_run(
460 tmp_path, "demo.ps1", POWERSHELL_LITERALS, CommentType.powershell
461 )
462
463 assert [need.need["id"] for need in analyse.oneline_needs] == ["IMPL_PS_REAL"]
464 assert analyse.warnings == []
465
466
467def test_a_brace_after_the_start_sequence_is_a_marker_elsewhere(
468 tmp_path: Path,
469) -> None:
470 """The rule is PowerShell's: in another language, ``@{`` opens a marker as
471 before."""
472 analyse = _oneline_run(
473 tmp_path,
474 "demo.py",
475 "# @{ a }, ID_PY, impl\ndef f(): pass\n",
476 CommentType.python,
477 )
478
479 assert [need.need["id"] for need in analyse.oneline_needs] == ["ID_PY"]
480
481
482def test_a_powershell_title_cannot_open_with_a_parenthesis_or_a_brace(
483 tmp_path: Path,
484) -> None:
485 """The hashtable and array rule is deliberate and has a cost: a marker whose title
486 starts with ``(`` or ``{`` is read as ``@(`` or ``@{``, and is dropped without a
487 warning, as the documentation says. Only PowerShell pays it."""
488 source = (
489 "# @(Draft) Login handler, S1_PAREN, impl, [R]\n"
490 "# @{Braced} title, S1_BRACE, impl\n"
491 "function Start-Login { }\n"
492 )
493 analyse = _oneline_run(tmp_path, "login.ps1", source, CommentType.powershell)
494
495 assert analyse.oneline_needs == []
496 assert analyse.warnings == []
497
498 other = _oneline_run(tmp_path, "login.py", source, CommentType.python)
499 assert [need.need["id"] for need in other.oneline_needs] == [
500 "S1_PAREN",
501 "S1_BRACE",
502 ]