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