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