[docs]  1# @Test suite for source file discovery with gitignore support, TEST_DISC_1, test, [IMPL_DISC_1]
  2import json
  3from pathlib import Path
  4import subprocess
  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
 18@pytest.mark.parametrize(
 19    ("config", "msgs"),
 20    [
 21        (
 22            {
 23                "src_dir": 123,
 24                "exclude": ["exclude1", "exclude2"],
 25                "include": ["include1", "include2"],
 26                "gitignore": True,
 27                "comment_type": "cpp",
 28            },
 29            ["Schema validation error in field 'src_dir': 123 is not of type 'string'"],
 30        ),
 31        (
 32            {
 33                "src_dir": "/path/to/root",
 34                "exclude": ["exclude1", "exclude2"],
 35                "include": ["include1", "include2"],
 36                "gitignore": "TrueAsString",
 37                "comment_type": "cpp",
 38            },
 39            [
 40                "Schema validation error in field 'gitignore': 'TrueAsString' is not of type 'boolean'"
 41            ],
 42        ),
 43        (
 44            {
 45                "src_dir": "/path/to/root",
 46                "exclude": ["exclude1", "exclude2"],
 47                "include": ["include1", "include2"],
 48                "gitignore": True,
 49                "comment_type": "java",
 50            },
 51            [
 52                "Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'yaml']"
 53            ],
 54        ),
 55        (
 56            {
 57                "src_dir": "/path/to/root",
 58                "exclude": ["exclude1", "exclude2"],
 59                "include": ["include1", "include2"],
 60                "gitignore": True,
 61                "comment_type": ["cpp", "hpp"],
 62            },
 63            [
 64                "Schema validation error in field 'comment_type': ['cpp', 'hpp'] is not of type 'string'"
 65            ],
 66        ),
 67        (
 68            {
 69                "src_dir": "/path/to/root",
 70                "follow_links": "not_a_bool",
 71            },
 72            [
 73                "Schema validation error in field 'follow_links': 'not_a_bool' is not of type 'boolean'"
 74            ],
 75        ),
 76    ],
 77)
 78def test_schema_negative(config, msgs):
 79    source_discover_config = SourceDiscoverConfig(**config)
 80    errors = source_discover_config.check_schema()
 81    assert sorted(errors) == sorted(msgs)
 82
 83
 84@pytest.mark.parametrize(
 85    "config",
 86    [
 87        {},
 88        {
 89            "src_dir": "/path/to/root",
 90            "exclude": ["exclude1", "exclude2"],
 91            "include": ["include1", "include2"],
 92            "gitignore": True,
 93            "comment_type": "cpp",
 94        },
 95        {
 96            "src_dir": "/path/to/root",
 97            "exclude": ["exclude1", "exclude2"],
 98            "include": ["include1", "include2"],
 99            "gitignore": True,
100            "comment_type": "python",
101        },
102        {
103            "src_dir": "/path/to/root",
104            "follow_links": True,
105        },
106    ],
107)
108def test_schema_positive(config):
109    source_discover_config = SourceDiscoverConfig(**config)
110    errors = source_discover_config.check_schema()
111    assert len(errors) == 0
112
113
114@pytest.mark.parametrize(
115    ("config", "num_files", "suffix"),
116    [
117        (
118            {
119                "gitignore": False,
120            },
121            4,
122            "",
123        ),
124        (
125            {
126                "gitignore": True,
127            },
128            3,
129            "",
130        ),
131        (
132            {
133                "gitignore": True,
134                "exclude": ["charge/*.cpp"],
135                "include": ["**/*.cpp"],
136            },
137            # With ignore-python, include patterns whitelist files (overriding
138            # gitignore) and exclude patterns are applied after, so both
139            # charge/*.cpp files are excluded resulting in 2 instead of 4.
140            2,
141            "",
142        ),
143        (
144            {
145                "gitignore": True,
146                "exclude": ["charge/*.cpp"],
147            },
148            2,
149            "",
150        ),
151        (
152            {"gitignore": False, "comment_type": "cpp"},
153            4,
154            "cpp",
155        ),
156    ],
157)
158def test_source_discover(
159    config: SourceDiscoverConfigType,
160    num_files: int,
161    suffix: str,
162    source_directory: Path,
163) -> None:
164    config["src_dir"] = source_directory
165    src_discover_config = SourceDiscoverConfig(**config)
166    source_discover = SourceDiscover(src_discover_config)
167    assert len(source_discover.source_paths) == num_files
168    if suffix:
169        assert all(path.suffix == ".cpp" for path in source_discover.source_paths)
170
171
172@pytest.fixture(scope="function")
173def create_source_files(tmp_path: Path) -> Path:
174    for file_types in COMMENT_FILETYPE.values():
175        for ext in file_types:
176            (tmp_path / f"file.{ext}").touch()
177    return tmp_path
178
179
180@pytest.mark.parametrize(
181    ("comment_type", "nums_files"),
182    [
183        ("cpp", len(COMMENT_FILETYPE["cpp"])),
184        ("python", len(COMMENT_FILETYPE["python"])),
185        ("bash", len(COMMENT_FILETYPE["bash"])),
186    ],
187)
188def test_comment_filetype(
189    comment_type: str, nums_files: int, create_source_files: Path
190) -> None:
191    src_dir = create_source_files
192
193    config = SourceDiscoverConfig(
194        src_dir=src_dir, comment_type=comment_type, gitignore=False
195    )
196    source_discover = SourceDiscover(config)
197    assert len(source_discover.source_paths) == nums_files
198
199
200def test_jsonc_discover_gate() -> None:
201    """`.jsonc` is always discovered; `.json` only when it opens with a comment."""
202    jsonc_dir = Path(__file__).parent / "data" / "jsonc"
203    config = SourceDiscoverConfig(
204        src_dir=jsonc_dir, comment_type="jsonc", gitignore=False
205    )
206    discovered = {p.name for p in SourceDiscover(config).source_paths}
207    assert "demo.jsonc" in discovered
208    assert "with_modeline.json" in discovered
209    assert "plain.json" not in discovered
210
211
212def test_follow_links(tmp_path: Path) -> None:
213    """Test that follow_links controls whether symbolic links are followed."""
214    # Create a real directory with a source file
215    real_dir = tmp_path / "real"
216    real_dir.mkdir()
217    (real_dir / "source.cpp").write_text("// test")
218
219    # Create a project directory with a symlink to the real directory
220    project_dir = tmp_path / "project"
221    project_dir.mkdir()
222    (project_dir / "direct.cpp").write_text("// direct")
223    link = project_dir / "linked"
224    link.symlink_to(real_dir)
225
226    # Without follow_links, symlinked files should not be discovered
227    config_no_follow = SourceDiscoverConfig(
228        src_dir=project_dir, gitignore=False, follow_links=False
229    )
230    discover_no_follow = SourceDiscover(config_no_follow)
231    discovered_names = {p.name for p in discover_no_follow.source_paths}
232    assert "direct.cpp" in discovered_names
233    assert "source.cpp" not in discovered_names
234
235    # With follow_links, symlinked files should be discovered
236    config_follow = SourceDiscoverConfig(
237        src_dir=project_dir, gitignore=False, follow_links=True
238    )
239    discover_follow = SourceDiscover(config_follow)
240    discovered_names = {p.name for p in discover_follow.source_paths}
241    assert "direct.cpp" in discovered_names
242    assert "source.cpp" in discovered_names
243
244
245def _load_discover_fixtures() -> list[dict]:
246    with FIXTURES_PATH.open(encoding="utf-8") as f:
247        return json.load(f)
248
249
250@pytest.mark.parametrize(
251    "case",
252    _load_discover_fixtures(),
253    ids=lambda c: c["name"],
254)
255def test_discover_fixture(case: dict, tmp_path: Path) -> None:
256    """Run portable discovery test cases from the shared JSON fixture."""
257    # Create files
258    for rel_path, content in case["files"].items():
259        file_path = tmp_path / rel_path
260        file_path.parent.mkdir(parents=True, exist_ok=True)
261        file_path.write_text(content, encoding="utf-8")
262
263    # Optionally initialise a git repo (required for .gitignore support)
264    if case.get("git_init", False):
265        subprocess.run(
266            ["git", "init"],  # noqa: S607
267            cwd=str(tmp_path),
268            check=True,
269            capture_output=True,
270        )
271
272    cfg = case["config"]
273    src_dir = tmp_path / cfg["src_dir"]
274
275    config = SourceDiscoverConfig(
276        src_dir=src_dir,
277        include=cfg.get("include", []),
278        exclude=cfg.get("exclude", []),
279        gitignore=cfg.get("gitignore", True),
280        comment_type=cfg.get("comment_type", "cpp"),
281    )
282
283    discover = SourceDiscover(config)
284
285    # Convert discovered paths to paths relative to tmp_path for comparison
286    discovered_relative = sorted(
287        str(p.relative_to(tmp_path)) for p in discover.source_paths
288    )
289
290    # Normalise expected paths to use the OS path separator
291    expected = sorted(str(Path(p)) for p in case["expected"])
292
293    assert discovered_relative == expected, (
294        f"Case '{case['name']}': expected {expected}, got {discovered_relative}"
295    )