[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 ['cpp', 'cs', '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    ],
186)
187def test_comment_filetype(
188    comment_type: str, nums_files: int, create_source_files: Path
189) -> None:
190    src_dir = create_source_files
191
192    config = SourceDiscoverConfig(
193        src_dir=src_dir, comment_type=comment_type, gitignore=False
194    )
195    source_discover = SourceDiscover(config)
196    assert len(source_discover.source_paths) == nums_files
197
198
199def test_follow_links(tmp_path: Path) -> None:
200    """Test that follow_links controls whether symbolic links are followed."""
201    # Create a real directory with a source file
202    real_dir = tmp_path / "real"
203    real_dir.mkdir()
204    (real_dir / "source.cpp").write_text("// test")
205
206    # Create a project directory with a symlink to the real directory
207    project_dir = tmp_path / "project"
208    project_dir.mkdir()
209    (project_dir / "direct.cpp").write_text("// direct")
210    link = project_dir / "linked"
211    link.symlink_to(real_dir)
212
213    # Without follow_links, symlinked files should not be discovered
214    config_no_follow = SourceDiscoverConfig(
215        src_dir=project_dir, gitignore=False, follow_links=False
216    )
217    discover_no_follow = SourceDiscover(config_no_follow)
218    discovered_names = {p.name for p in discover_no_follow.source_paths}
219    assert "direct.cpp" in discovered_names
220    assert "source.cpp" not in discovered_names
221
222    # With follow_links, symlinked files should be discovered
223    config_follow = SourceDiscoverConfig(
224        src_dir=project_dir, gitignore=False, follow_links=True
225    )
226    discover_follow = SourceDiscover(config_follow)
227    discovered_names = {p.name for p in discover_follow.source_paths}
228    assert "direct.cpp" in discovered_names
229    assert "source.cpp" in discovered_names
230
231
232def _load_discover_fixtures() -> list[dict]:
233    with FIXTURES_PATH.open(encoding="utf-8") as f:
234        return json.load(f)
235
236
237@pytest.mark.parametrize(
238    "case",
239    _load_discover_fixtures(),
240    ids=lambda c: c["name"],
241)
242def test_discover_fixture(case: dict, tmp_path: Path) -> None:
243    """Run portable discovery test cases from the shared JSON fixture."""
244    # Create files
245    for rel_path, content in case["files"].items():
246        file_path = tmp_path / rel_path
247        file_path.parent.mkdir(parents=True, exist_ok=True)
248        file_path.write_text(content, encoding="utf-8")
249
250    # Optionally initialise a git repo (required for .gitignore support)
251    if case.get("git_init", False):
252        subprocess.run(
253            ["git", "init"],  # noqa: S607
254            cwd=str(tmp_path),
255            check=True,
256            capture_output=True,
257        )
258
259    cfg = case["config"]
260    src_dir = tmp_path / cfg["src_dir"]
261
262    config = SourceDiscoverConfig(
263        src_dir=src_dir,
264        include=cfg.get("include", []),
265        exclude=cfg.get("exclude", []),
266        gitignore=cfg.get("gitignore", True),
267        comment_type=cfg.get("comment_type", "cpp"),
268    )
269
270    discover = SourceDiscover(config)
271
272    # Convert discovered paths to paths relative to tmp_path for comparison
273    discovered_relative = sorted(
274        str(p.relative_to(tmp_path)) for p in discover.source_paths
275    )
276
277    # Normalise expected paths to use the OS path separator
278    expected = sorted(str(Path(p)) for p in case["expected"])
279
280    assert discovered_relative == expected, (
281        f"Case '{case['name']}': expected {expected}, got {discovered_relative}"
282    )