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