[docs]  1# @Test suite for source file discovery with gitignore support, TEST_DISC_1, test, [IMPL_DISC_1]
  2import json
  3import subprocess
  4from pathlib import Path
  5
  6import pytest
  7
  8from sphinx_codelinks.source_discover.config import (
  9    COMMENT_FILETYPE,
 10    SourceDiscoverConfig,
 11    SourceDiscoverConfigType,
 12)
 13from sphinx_codelinks.source_discover.source_discover import SourceDiscover
 14
 15FIXTURES_PATH = Path(__file__).parent / "data" / "discover_fixtures.json"
 16
 17
 18def test_source_directory_is_worker_local(
 19    source_directory: Path, tmp_path_factory: pytest.TempPathFactory
 20) -> None:
 21    assert source_directory.is_relative_to(tmp_path_factory.getbasetemp())
 22    assert (source_directory / "charge" / "demo_1.cpp").is_file()
 23    assert (source_directory / ".gitignore").read_text(
 24        encoding="utf-8"
 25    ) == "demo_1.cpp\n"
 26
 27
 28@pytest.mark.parametrize(
 29    ("config", "msgs"),
 30    [
 31        (
 32            {
 33                "src_dir": 123,
 34                "exclude": ["exclude1", "exclude2"],
 35                "include": ["include1", "include2"],
 36                "gitignore": True,
 37                "comment_type": "cpp",
 38            },
 39            ["Schema validation error in field 'src_dir': 123 is not of type 'string'"],
 40        ),
 41        (
 42            {
 43                "src_dir": "/path/to/root",
 44                "exclude": ["exclude1", "exclude2"],
 45                "include": ["include1", "include2"],
 46                "gitignore": "TrueAsString",
 47                "comment_type": "cpp",
 48            },
 49            [
 50                "Schema validation error in field 'gitignore': 'TrueAsString' is not of type 'boolean'"
 51            ],
 52        ),
 53        (
 54            {
 55                "src_dir": "/path/to/root",
 56                "exclude": ["exclude1", "exclude2"],
 57                "include": ["include1", "include2"],
 58                "gitignore": True,
 59                "comment_type": "java",
 60            },
 61            [
 62                "Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'yaml']"
 63            ],
 64        ),
 65        (
 66            {
 67                "src_dir": "/path/to/root",
 68                "exclude": ["exclude1", "exclude2"],
 69                "include": ["include1", "include2"],
 70                "gitignore": True,
 71                "comment_type": ["cpp", "hpp"],
 72            },
 73            [
 74                "Schema validation error in field 'comment_type': ['cpp', 'hpp'] is not of type 'string'"
 75            ],
 76        ),
 77        (
 78            {
 79                "src_dir": "/path/to/root",
 80                "follow_links": "not_a_bool",
 81            },
 82            [
 83                "Schema validation error in field 'follow_links': 'not_a_bool' is not of type 'boolean'"
 84            ],
 85        ),
 86    ],
 87)
 88def test_schema_negative(config, msgs):
 89    source_discover_config = SourceDiscoverConfig(**config)
 90    errors = source_discover_config.check_schema()
 91    assert sorted(errors) == sorted(msgs)
 92
 93
 94@pytest.mark.parametrize(
 95    "config",
 96    [
 97        {},
 98        {
 99            "src_dir": "/path/to/root",
100            "exclude": ["exclude1", "exclude2"],
101            "include": ["include1", "include2"],
102            "gitignore": True,
103            "comment_type": "cpp",
104        },
105        {
106            "src_dir": "/path/to/root",
107            "exclude": ["exclude1", "exclude2"],
108            "include": ["include1", "include2"],
109            "gitignore": True,
110            "comment_type": "python",
111        },
112        {
113            "src_dir": "/path/to/root",
114            "follow_links": True,
115        },
116    ],
117)
118def test_schema_positive(config):
119    source_discover_config = SourceDiscoverConfig(**config)
120    errors = source_discover_config.check_schema()
121    assert len(errors) == 0
122
123
124@pytest.mark.parametrize(
125    ("config", "num_files", "suffix"),
126    [
127        (
128            {
129                "gitignore": False,
130            },
131            4,
132            "",
133        ),
134        (
135            {
136                "gitignore": True,
137            },
138            3,
139            "",
140        ),
141        (
142            {
143                "gitignore": True,
144                "exclude": ["charge/*.cpp"],
145                "include": ["**/*.cpp"],
146            },
147            # With ignore-python, include patterns whitelist files (overriding
148            # gitignore) and exclude patterns are applied after, so both
149            # charge/*.cpp files are excluded resulting in 2 instead of 4.
150            2,
151            "",
152        ),
153        (
154            {
155                "gitignore": True,
156                "exclude": ["charge/*.cpp"],
157            },
158            2,
159            "",
160        ),
161        (
162            {"gitignore": False, "comment_type": "cpp"},
163            4,
164            "cpp",
165        ),
166    ],
167)
168def test_source_discover(
169    config: SourceDiscoverConfigType,
170    num_files: int,
171    suffix: str,
172    source_directory: Path,
173) -> None:
174    config["src_dir"] = source_directory
175    src_discover_config = SourceDiscoverConfig(**config)
176    source_discover = SourceDiscover(src_discover_config)
177    assert len(source_discover.source_paths) == num_files
178    if suffix:
179        assert all(path.suffix == ".cpp" for path in source_discover.source_paths)
180
181
182@pytest.fixture(scope="function")
183def create_source_files(tmp_path: Path) -> Path:
184    for file_types in COMMENT_FILETYPE.values():
185        for ext in file_types:
186            (tmp_path / f"file.{ext}").touch()
187    return tmp_path
188
189
190@pytest.mark.parametrize(
191    ("comment_type", "nums_files"),
192    [
193        ("cpp", len(COMMENT_FILETYPE["cpp"])),
194        ("python", len(COMMENT_FILETYPE["python"])),
195        ("bash", len(COMMENT_FILETYPE["bash"])),
196    ],
197)
198def test_comment_filetype(
199    comment_type: str, nums_files: int, create_source_files: Path
200) -> None:
201    src_dir = create_source_files
202
203    config = SourceDiscoverConfig(
204        src_dir=src_dir, comment_type=comment_type, gitignore=False
205    )
206    source_discover = SourceDiscover(config)
207    assert len(source_discover.source_paths) == nums_files
208
209
210def test_jsonc_discover_gate() -> None:
211    """`.jsonc` is always discovered; `.json` only when it opens with a comment."""
212    jsonc_dir = Path(__file__).parent / "data" / "jsonc"
213    config = SourceDiscoverConfig(
214        src_dir=jsonc_dir, comment_type="jsonc", gitignore=False
215    )
216    discovered = {p.name for p in SourceDiscover(config).source_paths}
217    assert "demo.jsonc" in discovered
218    assert "with_modeline.json" in discovered
219    assert "plain.json" not in discovered
220
221
222def test_follow_links(tmp_path: Path) -> None:
223    """Test that follow_links controls whether symbolic links are followed."""
224    # Create a project directory with a real directory the walk excludes, holding a
225    # source file: inside the project, since a file outside it is never discovered
226    # (#2062, ``test_a_link_to_outside_the_root_is_not_listed``)
227    project_dir = tmp_path / "project"
228    real_dir = project_dir / "real"
229    real_dir.mkdir(parents=True)
230    (real_dir / "source.cpp").write_text("// test")
231    (project_dir / "direct.cpp").write_text("// direct")
232    # and a symlink to the real directory
233    link = project_dir / "linked"
234    link.symlink_to(real_dir)
235
236    # Without follow_links, symlinked files should not be discovered
237    config_no_follow = SourceDiscoverConfig(
238        src_dir=project_dir, gitignore=False, exclude=["real/**"], follow_links=False
239    )
240    discover_no_follow = SourceDiscover(config_no_follow)
241    discovered_names = {p.name for p in discover_no_follow.source_paths}
242    assert "direct.cpp" in discovered_names
243    assert "source.cpp" not in discovered_names
244
245    # With follow_links, symlinked files should be discovered
246    config_follow = SourceDiscoverConfig(
247        src_dir=project_dir, gitignore=False, exclude=["real/**"], follow_links=True
248    )
249    discover_follow = SourceDiscover(config_follow)
250    discovered_names = {p.name for p in discover_follow.source_paths}
251    assert "direct.cpp" in discovered_names
252    assert "source.cpp" in discovered_names
253
254
255def _load_discover_fixtures() -> list[dict]:
256    with FIXTURES_PATH.open(encoding="utf-8") as f:
257        return json.load(f)
258
259
260@pytest.mark.parametrize(
261    "case",
262    _load_discover_fixtures(),
263    ids=lambda c: c["name"],
264)
265def test_discover_fixture(case: dict, tmp_path: Path) -> None:
266    """Run portable discovery test cases from the shared JSON fixture."""
267    # Create files
268    for rel_path, content in case["files"].items():
269        file_path = tmp_path / rel_path
270        file_path.parent.mkdir(parents=True, exist_ok=True)
271        file_path.write_text(content, encoding="utf-8")
272
273    # Optionally initialise a git repo (required for .gitignore support)
274    if case.get("git_init", False):
275        subprocess.run(
276            ["git", "init"],
277            cwd=str(tmp_path),
278            check=True,
279            capture_output=True,
280        )
281
282    cfg = case["config"]
283    src_dir = tmp_path / cfg["src_dir"]
284
285    config = SourceDiscoverConfig(
286        src_dir=src_dir,
287        include=cfg.get("include", []),
288        exclude=cfg.get("exclude", []),
289        gitignore=cfg.get("gitignore", True),
290        comment_type=cfg.get("comment_type", "cpp"),
291    )
292
293    discover = SourceDiscover(config)
294
295    # Convert discovered paths to paths relative to tmp_path for comparison
296    discovered_relative = sorted(
297        str(p.relative_to(tmp_path)) for p in discover.source_paths
298    )
299
300    # Normalise expected paths to use the OS path separator
301    expected = sorted(str(Path(p)) for p in case["expected"])
302
303    assert discovered_relative == expected, (
304        f"Case '{case['name']}': expected {expected}, got {discovered_relative}"
305    )
306
307
308def _links_tree(tmp_path: Path) -> Path:
309    """``root/{a.cpp, sub/b.cpp}`` with four links: a file and a directory link inside
310    the root (``b_link.cpp`` -> ``sub/b.cpp``, ``dirlink_in`` -> ``sub``), and a file
311    and a directory link to outside it (``ext_link.cpp`` -> ``outside/ext.cpp``,
312    ``dirlink_out`` -> ``outside/odir``). Returns the root."""
313    root = tmp_path / "root"
314    (root / "sub").mkdir(parents=True)
315    (tmp_path / "outside" / "odir").mkdir(parents=True)
316    (root / "a.cpp").write_text("// a\n", encoding="utf-8")
317    (root / "sub" / "b.cpp").write_text("// b\n", encoding="utf-8")
318    (tmp_path / "outside" / "ext.cpp").write_text("// ext\n", encoding="utf-8")
319    (tmp_path / "outside" / "odir" / "o.cpp").write_text("// o\n", encoding="utf-8")
320    (root / "b_link.cpp").symlink_to(root / "sub" / "b.cpp")
321    (root / "dirlink_in").symlink_to(root / "sub", target_is_directory=True)
322    (root / "ext_link.cpp").symlink_to(tmp_path / "outside" / "ext.cpp")
323    (root / "dirlink_out").symlink_to(
324        tmp_path / "outside" / "odir", target_is_directory=True
325    )
326    return root
327
328
329def _listing(root: Path, *, follow_links: bool) -> list[str]:
330    """What discovery lists under ``root``, POSIX and relative to ``root``'s parent
331    (``root.parent`` resolved: the listing is of canonical paths)."""
332    config = SourceDiscoverConfig(
333        src_dir=root, gitignore=False, follow_links=follow_links
334    )
335    base = root.parent.resolve()
336    return [
337        path.relative_to(base).as_posix()
338        for path in SourceDiscover(config).source_paths
339    ]
340
341
342def test_a_file_link_inside_the_root_is_listed_once(tmp_path: Path) -> None:
343    """``b_link.cpp`` is ``sub/b.cpp``: one entry, the target's, not one per path to it.
344
345    A symlinked file is listed even with ``follow_links = false`` (``is_file()``
346    follows the link) -- ubCode skips it; a parity gap kept as it is. A link to a file
347    OUTSIDE the root is not listed (#2062).
348    """
349    assert _listing(_links_tree(tmp_path), follow_links=False) == [
350        "root/a.cpp",
351        "root/sub/b.cpp",
352    ]
353
354
355def test_a_followed_directory_link_inside_the_root_lists_each_file_once(
356    tmp_path: Path,
357) -> None:
358    """With ``follow_links = true`` ``sub/b.cpp`` is reached three ways (``sub/``,
359    ``b_link.cpp`` and ``dirlink_in/``): still one entry. The followed link to outside
360    the root lists nothing (#2062)."""
361    assert _listing(_links_tree(tmp_path), follow_links=True) == [
362        "root/a.cpp",
363        "root/sub/b.cpp",
364    ]
365
366
367def test_a_link_free_listing_is_sorted_by_full_path(tmp_path: Path) -> None:
368    """Without links nothing is deduplicated, and the order is the full path's: ``-``
369    and ``.`` sort before the separator, ``_`` after it (on POSIX and on Windows)."""
370    root = tmp_path / "root"
371    for relative in [
372        "b.cpp",
373        "sub_x.cpp",
374        "z/y/x.cpp",
375        "sub/c.cpp",
376        "a.cpp",
377        "sub.cpp",
378        "sub-x/d.cpp",
379    ]:
380        path = root / relative
381        path.parent.mkdir(parents=True, exist_ok=True)
382        path.write_text("// x\n", encoding="utf-8")
383
384    assert _listing(root, follow_links=False) == [
385        "root/a.cpp",
386        "root/b.cpp",
387        "root/sub-x/d.cpp",
388        "root/sub.cpp",
389        "root/sub/c.cpp",
390        "root/sub_x.cpp",
391        "root/z/y/x.cpp",
392    ]
393    assert _listing(root, follow_links=True) == _listing(root, follow_links=False)
394
395
396@pytest.mark.parametrize("follow_links", [False, True])
397def test_a_link_to_outside_the_root_is_not_listed(
398    tmp_path: Path, follow_links: bool
399) -> None:
400    """Neither a file link to outside the root nor a followed directory link's files
401    are listed, either way (#2062): every record and copy is relative to the root."""
402    listing = _listing(_links_tree(tmp_path), follow_links=follow_links)
403    assert not any(path.startswith("outside/") for path in listing)
404    assert not any("link" in path for path in listing)