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