1use super::FixtureDatabase;
11use once_cell::sync::Lazy;
12use rustpython_parser::ast::{Expr, Stmt};
13use std::collections::HashSet;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tracing::{debug, info};
17
18static STDLIB_MODULES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
20 [
21 "os",
22 "sys",
23 "re",
24 "json",
25 "typing",
26 "collections",
27 "functools",
28 "itertools",
29 "pathlib",
30 "datetime",
31 "time",
32 "math",
33 "random",
34 "copy",
35 "io",
36 "abc",
37 "contextlib",
38 "dataclasses",
39 "enum",
40 "logging",
41 "unittest",
42 "asyncio",
43 "concurrent",
44 "multiprocessing",
45 "threading",
46 "subprocess",
47 "shutil",
48 "tempfile",
49 "glob",
50 "fnmatch",
51 "pickle",
52 "sqlite3",
53 "urllib",
54 "http",
55 "email",
56 "html",
57 "xml",
58 "socket",
59 "ssl",
60 "select",
61 "signal",
62 "struct",
63 "codecs",
64 "textwrap",
65 "string",
66 "difflib",
67 "inspect",
68 "dis",
69 "traceback",
70 "warnings",
71 "weakref",
72 "types",
73 "importlib",
74 "pkgutil",
75 "pprint",
76 "reprlib",
77 "numbers",
78 "decimal",
79 "fractions",
80 "statistics",
81 "hashlib",
82 "hmac",
83 "secrets",
84 "base64",
85 "binascii",
86 "zlib",
87 "gzip",
88 "bz2",
89 "lzma",
90 "zipfile",
91 "tarfile",
92 "csv",
93 "configparser",
94 "argparse",
95 "getopt",
96 "getpass",
97 "platform",
98 "errno",
99 "ctypes",
100 "__future__",
101 ]
102 .into_iter()
103 .collect()
104});
105
106#[derive(Debug, Clone)]
108#[allow(dead_code)] pub struct FixtureImport {
110 pub module_path: String,
112 pub is_star_import: bool,
114 pub imported_names: Vec<String>,
116 pub importing_file: PathBuf,
118 pub line: usize,
120}
121
122impl FixtureDatabase {
123 pub(crate) fn extract_fixture_imports(
126 &self,
127 stmts: &[Stmt],
128 file_path: &Path,
129 line_index: &[usize],
130 ) -> Vec<FixtureImport> {
131 let mut imports = Vec::new();
132
133 for stmt in stmts {
134 if let Stmt::ImportFrom(import_from) = stmt {
135 let mut module = import_from
137 .module
138 .as_ref()
139 .map(|m| m.to_string())
140 .unwrap_or_default();
141
142 if let Some(ref level) = import_from.level {
147 let dots = ".".repeat(level.to_usize());
148 module = dots + &module;
149 }
150
151 if self.is_standard_library_module(&module) {
153 continue;
154 }
155
156 let line =
157 self.get_line_from_offset(import_from.range.start().to_usize(), line_index);
158
159 let is_star = import_from
161 .names
162 .iter()
163 .any(|alias| alias.name.as_str() == "*");
164
165 if is_star {
166 imports.push(FixtureImport {
167 module_path: module,
168 is_star_import: true,
169 imported_names: Vec::new(),
170 importing_file: file_path.to_path_buf(),
171 line,
172 });
173 } else {
174 let names: Vec<String> = import_from
176 .names
177 .iter()
178 .map(|alias| alias.asname.as_ref().unwrap_or(&alias.name).to_string())
179 .collect();
180
181 if !names.is_empty() {
182 imports.push(FixtureImport {
183 module_path: module,
184 is_star_import: false,
185 imported_names: names,
186 importing_file: file_path.to_path_buf(),
187 line,
188 });
189 }
190 }
191 }
192 }
193
194 imports
195 }
196
197 pub(crate) fn extract_pytest_plugins(&self, stmts: &[Stmt]) -> Vec<String> {
207 let mut modules = Vec::new();
208
209 for stmt in stmts {
210 let value = match stmt {
211 Stmt::Assign(assign) => {
212 let is_pytest_plugins = assign.targets.iter().any(|target| {
213 matches!(target, Expr::Name(name) if name.id.as_str() == "pytest_plugins")
214 });
215 if !is_pytest_plugins {
216 continue;
217 }
218 assign.value.as_ref()
219 }
220 Stmt::AnnAssign(ann_assign) => {
221 let is_pytest_plugins = matches!(
222 ann_assign.target.as_ref(),
223 Expr::Name(name) if name.id.as_str() == "pytest_plugins"
224 );
225 if !is_pytest_plugins {
226 continue;
227 }
228 match ann_assign.value.as_ref() {
229 Some(v) => v.as_ref(),
230 None => continue,
231 }
232 }
233 _ => continue,
234 };
235
236 modules.clear();
238
239 match value {
240 Expr::Constant(c) => {
241 if let rustpython_parser::ast::Constant::Str(s) = &c.value {
242 modules.push(s.to_string());
243 }
244 }
245 Expr::List(list) => {
246 for elt in &list.elts {
247 if let Expr::Constant(c) = elt {
248 if let rustpython_parser::ast::Constant::Str(s) = &c.value {
249 modules.push(s.to_string());
250 }
251 }
252 }
253 }
254 Expr::Tuple(tuple) => {
255 for elt in &tuple.elts {
256 if let Expr::Constant(c) = elt {
257 if let rustpython_parser::ast::Constant::Str(s) = &c.value {
258 modules.push(s.to_string());
259 }
260 }
261 }
262 }
263 _ => {
264 debug!("Ignoring dynamic pytest_plugins value (not a string/list/tuple)");
265 }
266 }
267 }
268
269 modules
270 }
271
272 fn is_standard_library_module(&self, module: &str) -> bool {
275 let first_part = module.split('.').next().unwrap_or(module);
276 STDLIB_MODULES.contains(first_part)
277 }
278
279 pub(crate) fn resolve_module_to_file(
282 &self,
283 module_path: &str,
284 importing_file: &Path,
285 ) -> Option<PathBuf> {
286 debug!(
287 "Resolving module '{}' from file {:?}",
288 module_path, importing_file
289 );
290
291 let parent_dir = importing_file.parent()?;
292
293 if module_path.starts_with('.') {
294 self.resolve_relative_import(module_path, parent_dir)
296 } else {
297 self.resolve_absolute_import(module_path, parent_dir)
299 }
300 }
301
302 fn resolve_relative_import(&self, module_path: &str, base_dir: &Path) -> Option<PathBuf> {
304 let mut current_dir = base_dir.to_path_buf();
305 let mut chars = module_path.chars().peekable();
306
307 while chars.peek() == Some(&'.') {
309 chars.next();
310 if chars.peek() != Some(&'.') {
311 break;
313 }
314 current_dir = current_dir.parent()?.to_path_buf();
316 }
317
318 let remaining: String = chars.collect();
319 if remaining.is_empty() {
320 let init_path = current_dir.join("__init__.py");
322 if init_path.exists() {
323 return Some(init_path);
324 }
325 return None;
326 }
327
328 self.find_module_file(&remaining, ¤t_dir)
329 }
330
331 fn resolve_absolute_import(&self, module_path: &str, start_dir: &Path) -> Option<PathBuf> {
334 let mut current_dir = start_dir.to_path_buf();
335
336 loop {
337 if let Some(path) = self.find_module_file(module_path, ¤t_dir) {
338 return Some(path);
339 }
340
341 match current_dir.parent() {
343 Some(parent) => current_dir = parent.to_path_buf(),
344 None => break,
345 }
346 }
347
348 for sp in self.site_packages_paths.lock().unwrap().iter() {
350 if let Some(path) = self.find_module_file(module_path, sp) {
351 return Some(path);
352 }
353 }
354
355 for install in self.editable_install_roots.lock().unwrap().iter() {
357 if let Some(path) = self.find_module_file(module_path, &install.source_root) {
358 return Some(path);
359 }
360 }
361
362 None
363 }
364
365 fn find_module_file(&self, module_path: &str, base_dir: &Path) -> Option<PathBuf> {
367 let parts: Vec<&str> = module_path.split('.').collect();
368 let mut current_path = base_dir.to_path_buf();
369
370 for (i, part) in parts.iter().enumerate() {
371 let is_last = i == parts.len() - 1;
372
373 if is_last {
374 let py_file = current_path.join(format!("{}.py", part));
376 if py_file.exists() {
377 return Some(py_file);
378 }
379
380 let canonical_py_file = self.get_canonical_path(py_file.clone());
382 if self.file_cache.contains_key(&canonical_py_file) {
383 return Some(py_file);
384 }
385
386 let package_init = current_path.join(part).join("__init__.py");
388 if package_init.exists() {
389 return Some(package_init);
390 }
391
392 let canonical_package_init = self.get_canonical_path(package_init.clone());
394 if self.file_cache.contains_key(&canonical_package_init) {
395 return Some(package_init);
396 }
397 } else {
398 current_path = current_path.join(part);
400 if !current_path.is_dir() {
401 return None;
402 }
403 }
404 }
405
406 None
407 }
408
409 pub fn get_imported_fixtures(
415 &self,
416 file_path: &Path,
417 visited: &mut HashSet<PathBuf>,
418 ) -> HashSet<String> {
419 let canonical_path = self.get_canonical_path(file_path.to_path_buf());
420
421 if visited.contains(&canonical_path) {
423 debug!("Circular import detected for {:?}, skipping", file_path);
424 return HashSet::new();
425 }
426 visited.insert(canonical_path.clone());
427
428 let Some(content) = self.get_file_content(&canonical_path) else {
430 return HashSet::new();
431 };
432
433 let content_hash = Self::hash_content(&content);
434 let current_version = self
435 .definitions_version
436 .load(std::sync::atomic::Ordering::SeqCst);
437
438 if let Some(cached) = self.imported_fixtures_cache.get(&canonical_path) {
440 let (cached_content_hash, cached_version, cached_fixtures) = cached.value();
441 if *cached_content_hash == content_hash && *cached_version == current_version {
442 debug!("Cache hit for imported fixtures in {:?}", canonical_path);
443 return cached_fixtures.as_ref().clone();
444 }
445 }
446
447 let imported_fixtures = self.compute_imported_fixtures(&canonical_path, &content, visited);
449
450 self.imported_fixtures_cache.insert(
452 canonical_path.clone(),
453 (
454 content_hash,
455 current_version,
456 Arc::new(imported_fixtures.clone()),
457 ),
458 );
459
460 info!(
461 "Found {} imported fixtures for {:?}: {:?}",
462 imported_fixtures.len(),
463 file_path,
464 imported_fixtures
465 );
466
467 imported_fixtures
468 }
469
470 fn compute_imported_fixtures(
472 &self,
473 canonical_path: &Path,
474 content: &str,
475 visited: &mut HashSet<PathBuf>,
476 ) -> HashSet<String> {
477 let mut imported_fixtures = HashSet::new();
478
479 let Some(parsed) = self.get_parsed_ast(canonical_path, content) else {
480 return imported_fixtures;
481 };
482
483 let line_index = self.get_line_index(canonical_path, content);
484
485 if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
486 let imports = self.extract_fixture_imports(&module.body, canonical_path, &line_index);
487
488 for import in imports {
489 let Some(resolved_path) =
491 self.resolve_module_to_file(&import.module_path, canonical_path)
492 else {
493 debug!(
494 "Could not resolve module '{}' from {:?}",
495 import.module_path, canonical_path
496 );
497 continue;
498 };
499
500 let resolved_canonical = self.get_canonical_path(resolved_path);
501
502 debug!(
503 "Resolved import '{}' to {:?}",
504 import.module_path, resolved_canonical
505 );
506
507 if import.is_star_import {
508 if let Some(file_fixtures) = self.file_definitions.get(&resolved_canonical) {
511 for fixture_name in file_fixtures.iter() {
512 imported_fixtures.insert(fixture_name.clone());
513 }
514 }
515
516 let transitive = self.get_imported_fixtures(&resolved_canonical, visited);
518 imported_fixtures.extend(transitive);
519 } else {
520 for name in &import.imported_names {
522 if self.definitions.contains_key(name) {
523 imported_fixtures.insert(name.clone());
524 }
525 }
526 }
527 }
528
529 let plugin_modules = self.extract_pytest_plugins(&module.body);
531 for module_path in plugin_modules {
532 let Some(resolved_path) = self.resolve_module_to_file(&module_path, canonical_path)
533 else {
534 debug!(
535 "Could not resolve pytest_plugins module '{}' from {:?}",
536 module_path, canonical_path
537 );
538 continue;
539 };
540
541 let resolved_canonical = self.get_canonical_path(resolved_path);
542
543 debug!(
544 "Resolved pytest_plugins '{}' to {:?}",
545 module_path, resolved_canonical
546 );
547
548 if let Some(file_fixtures) = self.file_definitions.get(&resolved_canonical) {
549 for fixture_name in file_fixtures.iter() {
550 imported_fixtures.insert(fixture_name.clone());
551 }
552 }
553
554 let transitive = self.get_imported_fixtures(&resolved_canonical, visited);
555 imported_fixtures.extend(transitive);
556 }
557 }
558
559 imported_fixtures
560 }
561
562 pub fn is_fixture_imported_in_file(&self, fixture_name: &str, file_path: &Path) -> bool {
565 let mut visited = HashSet::new();
566 let imported = self.get_imported_fixtures(file_path, &mut visited);
567 imported.contains(fixture_name)
568 }
569}