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