[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    ]