Skip to main content

pytest_language_server/fixtures/
imports.rs

1//! Fixture import resolution.
2//!
3//! This module handles tracking and resolving fixtures that are imported
4//! into conftest.py or test files via `from X import *` or explicit imports.
5//!
6//! When a conftest.py has `from .pytest_fixtures import *`, all fixtures
7//! defined in that module become available as if they were defined in the
8//! conftest.py itself.
9
10use super::types::TypeImportSpec;
11use super::FixtureDatabase;
12use once_cell::sync::Lazy;
13use rustpython_parser::ast::{Expr, Stmt};
14use std::collections::{HashMap, HashSet};
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, OnceLock};
17use tracing::{debug, info, warn};
18
19/// Runtime stdlib module names populated from the venv's Python binary via
20/// `sys.stdlib_module_names` (Python ≥ 3.10).  When set, this takes
21/// precedence over the static [`STDLIB_MODULES`] fallback list in
22/// [`is_stdlib_module`].
23///
24/// Set at most once per process lifetime by [`try_init_stdlib_from_python`].
25static RUNTIME_STDLIB_MODULES: OnceLock<HashSet<String>> = OnceLock::new();
26
27/// Built-in fallback list of standard library module names for O(1) lookup.
28///
29/// Used when [`RUNTIME_STDLIB_MODULES`] has not been populated (no venv
30/// found, Python < 3.10, or the Python binary could not be executed).
31/// Intentionally conservative — it is better to misclassify an unknown
32/// third-party module as stdlib (and skip inserting a redundant import)
33/// than to misclassify a stdlib module as third-party.
34static STDLIB_MODULES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
35    [
36        "os",
37        "sys",
38        "re",
39        "json",
40        "typing",
41        "collections",
42        "functools",
43        "itertools",
44        "pathlib",
45        "datetime",
46        "time",
47        "math",
48        "random",
49        "copy",
50        "io",
51        "abc",
52        "contextlib",
53        "dataclasses",
54        "enum",
55        "logging",
56        "unittest",
57        "asyncio",
58        "concurrent",
59        "multiprocessing",
60        "threading",
61        "subprocess",
62        "shutil",
63        "tempfile",
64        "glob",
65        "fnmatch",
66        "pickle",
67        "sqlite3",
68        "urllib",
69        "http",
70        "email",
71        "html",
72        "xml",
73        "socket",
74        "ssl",
75        "select",
76        "signal",
77        "struct",
78        "codecs",
79        "textwrap",
80        "string",
81        "difflib",
82        "inspect",
83        "dis",
84        "traceback",
85        "warnings",
86        "weakref",
87        "types",
88        "importlib",
89        "pkgutil",
90        "pprint",
91        "reprlib",
92        "numbers",
93        "decimal",
94        "fractions",
95        "statistics",
96        "hashlib",
97        "hmac",
98        "secrets",
99        "base64",
100        "binascii",
101        "zlib",
102        "gzip",
103        "bz2",
104        "lzma",
105        "zipfile",
106        "tarfile",
107        "csv",
108        "configparser",
109        "argparse",
110        "getopt",
111        "getpass",
112        "platform",
113        "errno",
114        "ctypes",
115        "__future__",
116    ]
117    .into_iter()
118    .collect()
119});
120
121/// Represents a fixture import in a Python file.
122#[derive(Debug, Clone)]
123#[allow(dead_code)] // Fields used for debugging and potential future features
124pub struct FixtureImport {
125    /// The module path being imported from (e.g., ".pytest_fixtures" or "pytest_fixtures")
126    pub module_path: String,
127    /// Whether this is a star import (`from X import *`)
128    pub is_star_import: bool,
129    /// Specific names imported (empty for star imports)
130    pub imported_names: Vec<String>,
131    /// The file that contains this import
132    pub importing_file: PathBuf,
133    /// Line number of the import statement
134    pub line: usize,
135}
136
137impl FixtureDatabase {
138    /// Extract fixture imports from a module's statements.
139    /// Returns a list of imports that could potentially bring in fixtures.
140    pub(crate) fn extract_fixture_imports(
141        &self,
142        stmts: &[Stmt],
143        file_path: &Path,
144        line_index: &[usize],
145    ) -> Vec<FixtureImport> {
146        let mut imports = Vec::new();
147
148        for stmt in stmts {
149            if let Stmt::ImportFrom(import_from) = stmt {
150                // Skip imports from standard library or well-known non-fixture modules
151                let mut module = import_from
152                    .module
153                    .as_ref()
154                    .map(|m| m.to_string())
155                    .unwrap_or_default();
156
157                // Add leading dots for relative imports
158                // level indicates how many parent directories to go up:
159                // level=1 means "from . import" (current package)
160                // level=2 means "from .. import" (parent package)
161                if let Some(ref level) = import_from.level {
162                    let dots = ".".repeat(level.to_usize());
163                    module = dots + &module;
164                }
165
166                // Skip obvious non-fixture imports
167                if self.is_standard_library_module(&module) {
168                    continue;
169                }
170
171                let line =
172                    self.get_line_from_offset(import_from.range.start().to_usize(), line_index);
173
174                // Check if this is a star import
175                let is_star = import_from
176                    .names
177                    .iter()
178                    .any(|alias| alias.name.as_str() == "*");
179
180                if is_star {
181                    imports.push(FixtureImport {
182                        module_path: module,
183                        is_star_import: true,
184                        imported_names: Vec::new(),
185                        importing_file: file_path.to_path_buf(),
186                        line,
187                    });
188                } else {
189                    // Collect specific imported names
190                    let names: Vec<String> = import_from
191                        .names
192                        .iter()
193                        .map(|alias| alias.asname.as_ref().unwrap_or(&alias.name).to_string())
194                        .collect();
195
196                    if !names.is_empty() {
197                        imports.push(FixtureImport {
198                            module_path: module,
199                            is_star_import: false,
200                            imported_names: names,
201                            importing_file: file_path.to_path_buf(),
202                            line,
203                        });
204                    }
205                }
206            }
207        }
208
209        imports
210    }
211
212    /// Extract module paths from `pytest_plugins` variable assignments.
213    ///
214    /// Handles both regular and annotated assignments:
215    /// - `pytest_plugins = "module"` (single string)
216    /// - `pytest_plugins = ["module_a", "module_b"]` (list)
217    /// - `pytest_plugins = ("module_a", "module_b")` (tuple)
218    /// - `pytest_plugins: list[str] = ["module_a"]` (annotated)
219    ///
220    /// If multiple assignments exist, only the last one is used (matching pytest semantics).
221    pub(crate) fn extract_pytest_plugins(&self, stmts: &[Stmt]) -> Vec<String> {
222        let mut modules = Vec::new();
223
224        for stmt in stmts {
225            let value = match stmt {
226                Stmt::Assign(assign) => {
227                    let is_pytest_plugins = assign.targets.iter().any(|target| {
228                        matches!(target, Expr::Name(name) if name.id.as_str() == "pytest_plugins")
229                    });
230                    if !is_pytest_plugins {
231                        continue;
232                    }
233                    assign.value.as_ref()
234                }
235                Stmt::AnnAssign(ann_assign) => {
236                    let is_pytest_plugins = matches!(
237                        ann_assign.target.as_ref(),
238                        Expr::Name(name) if name.id.as_str() == "pytest_plugins"
239                    );
240                    if !is_pytest_plugins {
241                        continue;
242                    }
243                    match ann_assign.value.as_ref() {
244                        Some(v) => v.as_ref(),
245                        None => continue,
246                    }
247                }
248                _ => continue,
249            };
250
251            // Last assignment wins: clear previous values
252            modules.clear();
253
254            match value {
255                Expr::Constant(c) => {
256                    if let rustpython_parser::ast::Constant::Str(s) = &c.value {
257                        modules.push(s.to_string());
258                    }
259                }
260                Expr::List(list) => {
261                    for elt in &list.elts {
262                        if let Expr::Constant(c) = elt {
263                            if let rustpython_parser::ast::Constant::Str(s) = &c.value {
264                                modules.push(s.to_string());
265                            }
266                        }
267                    }
268                }
269                Expr::Tuple(tuple) => {
270                    for elt in &tuple.elts {
271                        if let Expr::Constant(c) = elt {
272                            if let rustpython_parser::ast::Constant::Str(s) = &c.value {
273                                modules.push(s.to_string());
274                            }
275                        }
276                    }
277                }
278                _ => {
279                    debug!("Ignoring dynamic pytest_plugins value (not a string/list/tuple)");
280                }
281            }
282        }
283
284        modules
285    }
286
287    /// Check if a module is a standard library module that can't contain fixtures.
288    /// Uses a static HashSet for O(1) lookup instead of linear array search.
289    fn is_standard_library_module(&self, module: &str) -> bool {
290        is_stdlib_module(module)
291    }
292
293    /// Resolve a module path to a file path.
294    /// Handles both relative imports (starting with .) and absolute imports.
295    pub(crate) fn resolve_module_to_file(
296        &self,
297        module_path: &str,
298        importing_file: &Path,
299    ) -> Option<PathBuf> {
300        debug!(
301            "Resolving module '{}' from file {:?}",
302            module_path, importing_file
303        );
304
305        let parent_dir = importing_file.parent()?;
306
307        if module_path.starts_with('.') {
308            // Relative import
309            self.resolve_relative_import(module_path, parent_dir)
310        } else {
311            // Absolute import - search in the same directory tree
312            self.resolve_absolute_import(module_path, parent_dir)
313        }
314    }
315
316    /// Resolve a relative import like `.pytest_fixtures` or `..utils`.
317    fn resolve_relative_import(&self, module_path: &str, base_dir: &Path) -> Option<PathBuf> {
318        let mut current_dir = base_dir.to_path_buf();
319        let mut chars = module_path.chars().peekable();
320
321        // Count leading dots to determine how many directories to go up
322        while chars.peek() == Some(&'.') {
323            chars.next();
324            if chars.peek() != Some(&'.') {
325                // Single dot - stay in current directory
326                break;
327            }
328            // Additional dots - go up one directory
329            current_dir = current_dir.parent()?.to_path_buf();
330        }
331
332        let remaining: String = chars.collect();
333        if remaining.is_empty() {
334            // Import from __init__.py of current/parent package
335            let init_path = current_dir.join("__init__.py");
336            if init_path.exists() {
337                return Some(init_path);
338            }
339            return None;
340        }
341
342        self.find_module_file(&remaining, &current_dir)
343    }
344
345    /// Resolve an absolute import by searching up the directory tree,
346    /// then falling back to site-packages paths for venv plugin modules.
347    fn resolve_absolute_import(&self, module_path: &str, start_dir: &Path) -> Option<PathBuf> {
348        let mut current_dir = start_dir.to_path_buf();
349
350        loop {
351            if let Some(path) = self.find_module_file(module_path, &current_dir) {
352                return Some(path);
353            }
354
355            // Go up one directory
356            match current_dir.parent() {
357                Some(parent) => current_dir = parent.to_path_buf(),
358                None => break,
359            }
360        }
361
362        // Fallback: search in site-packages paths (for venv plugin pytest_plugins)
363        for sp in self.site_packages_paths.lock().unwrap().iter() {
364            if let Some(path) = self.find_module_file(module_path, sp) {
365                return Some(path);
366            }
367        }
368
369        // Fallback: search in editable install source roots
370        for install in self.editable_install_roots.lock().unwrap().iter() {
371            if let Some(path) = self.find_module_file(module_path, &install.source_root) {
372                return Some(path);
373            }
374        }
375
376        None
377    }
378
379    /// Find a module file given a dotted path and base directory.
380    fn find_module_file(&self, module_path: &str, base_dir: &Path) -> Option<PathBuf> {
381        let parts: Vec<&str> = module_path.split('.').collect();
382        let mut current_path = base_dir.to_path_buf();
383
384        for (i, part) in parts.iter().enumerate() {
385            let is_last = i == parts.len() - 1;
386
387            if is_last {
388                // Last part - could be a module file or a package
389                let py_file = current_path.join(format!("{}.py", part));
390                if py_file.exists() {
391                    return Some(py_file);
392                }
393
394                // Also check if the file is in the cache (for test files that don't exist on disk)
395                let canonical_py_file = self.get_canonical_path(py_file.clone());
396                if self.file_cache.contains_key(&canonical_py_file) {
397                    return Some(py_file);
398                }
399
400                // Check if it's a package with __init__.py
401                let package_init = current_path.join(part).join("__init__.py");
402                if package_init.exists() {
403                    return Some(package_init);
404                }
405
406                // Also check if the package __init__.py is in the cache
407                let canonical_package_init = self.get_canonical_path(package_init.clone());
408                if self.file_cache.contains_key(&canonical_package_init) {
409                    return Some(package_init);
410                }
411            } else {
412                // Not the last part - must be a directory
413                current_path = current_path.join(part);
414                if !current_path.is_dir() {
415                    return None;
416                }
417            }
418        }
419
420        None
421    }
422
423    /// Get fixtures that are re-exported from a file via imports.
424    /// This handles `from .module import *` patterns that bring fixtures into scope.
425    ///
426    /// Results are cached with content-hash and definitions-version based invalidation.
427    /// Returns fixture names that are available in `file_path` via imports.
428    pub fn get_imported_fixtures(
429        &self,
430        file_path: &Path,
431        visited: &mut HashSet<PathBuf>,
432    ) -> HashSet<String> {
433        let canonical_path = self.get_canonical_path(file_path.to_path_buf());
434
435        // Prevent circular imports
436        if visited.contains(&canonical_path) {
437            debug!("Circular import detected for {:?}, skipping", file_path);
438            return HashSet::new();
439        }
440        visited.insert(canonical_path.clone());
441
442        // Get the file content first (needed for cache validation)
443        let Some(content) = self.get_file_content(&canonical_path) else {
444            return HashSet::new();
445        };
446
447        let content_hash = Self::hash_content(&content);
448        let current_version = self
449            .definitions_version
450            .load(std::sync::atomic::Ordering::SeqCst);
451
452        // Check cache - valid if both content hash and definitions version match
453        if let Some(cached) = self.imported_fixtures_cache.get(&canonical_path) {
454            let (cached_content_hash, cached_version, cached_fixtures) = cached.value();
455            if *cached_content_hash == content_hash && *cached_version == current_version {
456                debug!("Cache hit for imported fixtures in {:?}", canonical_path);
457                return cached_fixtures.as_ref().clone();
458            }
459        }
460
461        // Compute imported fixtures
462        let imported_fixtures = self.compute_imported_fixtures(&canonical_path, &content, visited);
463
464        // Store in cache
465        self.imported_fixtures_cache.insert(
466            canonical_path.clone(),
467            (
468                content_hash,
469                current_version,
470                Arc::new(imported_fixtures.clone()),
471            ),
472        );
473
474        info!(
475            "Found {} imported fixtures for {:?}: {:?}",
476            imported_fixtures.len(),
477            file_path,
478            imported_fixtures
479        );
480
481        imported_fixtures
482    }
483
484    /// Internal method to compute imported fixtures without caching.
485    fn compute_imported_fixtures(
486        &self,
487        canonical_path: &Path,
488        content: &str,
489        visited: &mut HashSet<PathBuf>,
490    ) -> HashSet<String> {
491        let mut imported_fixtures = HashSet::new();
492
493        let Some(parsed) = self.get_parsed_ast(canonical_path, content) else {
494            return imported_fixtures;
495        };
496
497        let line_index = self.get_line_index(canonical_path, content);
498
499        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
500            let imports = self.extract_fixture_imports(&module.body, canonical_path, &line_index);
501
502            for import in imports {
503                // Resolve the import to a file path
504                let Some(resolved_path) =
505                    self.resolve_module_to_file(&import.module_path, canonical_path)
506                else {
507                    debug!(
508                        "Could not resolve module '{}' from {:?}",
509                        import.module_path, canonical_path
510                    );
511                    continue;
512                };
513
514                let resolved_canonical = self.get_canonical_path(resolved_path);
515
516                debug!(
517                    "Resolved import '{}' to {:?}",
518                    import.module_path, resolved_canonical
519                );
520
521                if import.is_star_import {
522                    // Star import: get all fixtures from the resolved file
523                    // First, get fixtures defined directly in that file
524                    if let Some(file_fixtures) = self.file_definitions.get(&resolved_canonical) {
525                        for fixture_name in file_fixtures.iter() {
526                            imported_fixtures.insert(fixture_name.clone());
527                        }
528                    }
529
530                    // Also recursively get fixtures imported into that file
531                    let transitive = self.get_imported_fixtures(&resolved_canonical, visited);
532                    imported_fixtures.extend(transitive);
533                } else {
534                    // Explicit import: only include the specified names if they are fixtures
535                    for name in &import.imported_names {
536                        if self.definitions.contains_key(name) {
537                            imported_fixtures.insert(name.clone());
538                        }
539                    }
540                }
541            }
542
543            // Process pytest_plugins variable (treated like star imports)
544            let plugin_modules = self.extract_pytest_plugins(&module.body);
545            for module_path in plugin_modules {
546                let Some(resolved_path) = self.resolve_module_to_file(&module_path, canonical_path)
547                else {
548                    debug!(
549                        "Could not resolve pytest_plugins module '{}' from {:?}",
550                        module_path, canonical_path
551                    );
552                    continue;
553                };
554
555                let resolved_canonical = self.get_canonical_path(resolved_path);
556
557                debug!(
558                    "Resolved pytest_plugins '{}' to {:?}",
559                    module_path, resolved_canonical
560                );
561
562                if let Some(file_fixtures) = self.file_definitions.get(&resolved_canonical) {
563                    for fixture_name in file_fixtures.iter() {
564                        imported_fixtures.insert(fixture_name.clone());
565                    }
566                }
567
568                let transitive = self.get_imported_fixtures(&resolved_canonical, visited);
569                imported_fixtures.extend(transitive);
570            }
571        }
572
573        imported_fixtures
574    }
575
576    /// Check if a fixture is available in a file via imports.
577    /// This is used in resolution to check conftest.py files that import fixtures.
578    pub fn is_fixture_imported_in_file(&self, fixture_name: &str, file_path: &Path) -> bool {
579        let mut visited = HashSet::new();
580        let imported = self.get_imported_fixtures(file_path, &mut visited);
581        imported.contains(fixture_name)
582    }
583}
584
585/// Check whether `module` (possibly dotted, e.g. `"collections.abc"`) belongs
586/// to the Python standard library.  Only the top-level package name is tested.
587///
588/// Checks [`RUNTIME_STDLIB_MODULES`] first (populated by
589/// [`try_init_stdlib_from_python`] when a venv with Python ≥ 3.10 is found),
590/// then falls back to the built-in [`STDLIB_MODULES`] list.
591///
592/// Exposed as a free function so that the code-action provider can classify
593/// import statements without access to a `FixtureDatabase` instance.
594pub(crate) fn is_stdlib_module(module: &str) -> bool {
595    let first_part = module.split('.').next().unwrap_or(module);
596    if let Some(runtime) = RUNTIME_STDLIB_MODULES.get() {
597        runtime.contains(first_part)
598    } else {
599        STDLIB_MODULES.contains(first_part)
600    }
601}
602
603/// Try to locate the Python interpreter inside a virtual environment.
604///
605/// Checks the standard Unix (`bin/python3`, `bin/python`) and Windows
606/// (`Scripts/python3.exe`, `Scripts/python.exe`) layouts in that order.
607/// Returns the first path that resolves to an existing regular file (or
608/// symlink to one).
609fn find_venv_python(venv_path: &Path) -> Option<PathBuf> {
610    // Unix / macOS layout
611    for name in &["python3", "python"] {
612        let candidate = venv_path.join("bin").join(name);
613        if candidate.is_file() {
614            return Some(candidate);
615        }
616    }
617    // Windows layout
618    for name in &["python3.exe", "python.exe"] {
619        let candidate = venv_path.join("Scripts").join(name);
620        if candidate.is_file() {
621            return Some(candidate);
622        }
623    }
624    None
625}
626
627/// Attempt to populate [`RUNTIME_STDLIB_MODULES`] by querying the Python
628/// interpreter found inside `venv_path`.
629///
630/// Runs:
631/// ```text
632/// python -I -c "import sys; print('\n'.join(sorted(sys.stdlib_module_names)))"
633/// ```
634///
635/// `sys.stdlib_module_names` was added in Python 3.10.  For older interpreters
636/// the command exits with a non-zero status and this function returns `false`,
637/// leaving [`is_stdlib_module`] to use the static fallback list.
638///
639/// The `OnceLock` guarantees that the runtime list is set at most once per
640/// process lifetime.  Subsequent calls return `true` immediately when the
641/// lock is already populated.
642///
643/// Returns `true` if the runtime list is now available (either just populated
644/// or already set by a previous call), `false` otherwise.
645pub(crate) fn try_init_stdlib_from_python(venv_path: &Path) -> bool {
646    // Already initialised — nothing to do.
647    if RUNTIME_STDLIB_MODULES.get().is_some() {
648        return true;
649    }
650
651    let Some(python) = find_venv_python(venv_path) else {
652        debug!(
653            "try_init_stdlib_from_python: no Python binary found in {:?}",
654            venv_path
655        );
656        return false;
657    };
658
659    debug!(
660        "try_init_stdlib_from_python: querying stdlib module names via {:?}",
661        python
662    );
663
664    // -I (isolated): ignore PYTHONPATH, user site, PYTHONSTARTUP — we only
665    // need a pristine `sys` module, nothing else.
666    let output = match std::process::Command::new(&python)
667        .args([
668            "-I",
669            "-c",
670            "import sys; print('\\n'.join(sorted(sys.stdlib_module_names)))",
671        ])
672        .output()
673    {
674        Ok(o) => o,
675        Err(e) => {
676            warn!(
677                "try_init_stdlib_from_python: failed to run {:?}: {}",
678                python, e
679            );
680            return false;
681        }
682    };
683
684    if !output.status.success() {
685        // Most likely Python < 3.10 — AttributeError on sys.stdlib_module_names.
686        debug!(
687            "try_init_stdlib_from_python: Python exited with {:?} \
688             (Python < 3.10 or other error) — using built-in stdlib list",
689            output.status.code()
690        );
691        return false;
692    }
693
694    let stdout = match std::str::from_utf8(&output.stdout) {
695        Ok(s) => s,
696        Err(e) => {
697            warn!(
698                "try_init_stdlib_from_python: Python output is not valid UTF-8: {}",
699                e
700            );
701            return false;
702        }
703    };
704
705    let modules: HashSet<String> = stdout
706        .lines()
707        .map(str::trim)
708        .filter(|l| !l.is_empty())
709        .map(str::to_owned)
710        .collect();
711
712    if modules.is_empty() {
713        warn!("try_init_stdlib_from_python: Python returned an empty module list");
714        return false;
715    }
716
717    info!(
718        "try_init_stdlib_from_python: loaded {} stdlib module names from {:?}",
719        modules.len(),
720        python
721    );
722
723    // Ignore the error — another thread may have raced us; either way the
724    // OnceLock now contains a valid set.
725    let _ = RUNTIME_STDLIB_MODULES.set(modules);
726    true
727}
728
729impl FixtureDatabase {
730    /// Convert a file path to a dotted Python module path string.
731    ///
732    /// Walks upward from the file's parent directory, accumulating package
733    /// components as long as each directory contains an `__init__.py` file.
734    /// Stops at the first directory that is not a package.
735    ///
736    /// **Note:** This function checks the filesystem (`__init__.py` existence)
737    /// at call time.  Results are captured in `FixtureDefinition::return_type_imports`
738    /// during analysis — if `__init__.py` files are added or removed after
739    /// analysis, re-analysis of the fixture file is required for the module
740    /// path to update.
741    ///
742    /// Examples (assuming `tests/` has `__init__.py` but `project/` does not):
743    /// - `/project/tests/conftest.py`      →  `"tests.conftest"`
744    /// - `/project/tests/__init__.py`      →  `"tests"`   (package root, stem dropped)
745    /// - `/tmp/conftest.py`                →  `"conftest"`   (no __init__.py found)
746    /// - `/project/tests/helpers/utils.py` →  `"tests.helpers.utils"` (nested package)
747    pub(crate) fn file_path_to_module_path(file_path: &Path) -> Option<String> {
748        let stem = file_path.file_stem()?.to_str()?;
749        // `__init__.py` *is* the package — its stem must not be added as a
750        // component.  The parent-directory traversal loop below will push the
751        // directory name (e.g. `pkg/sub/__init__.py` → `"pkg.sub"`).
752        // Any other file gets its stem as the first component
753        // (e.g. `pkg/sub/module.py` → `"pkg.sub.module"`).
754        let mut components = if stem == "__init__" {
755            vec![]
756        } else {
757            vec![stem.to_string()]
758        };
759        let mut current = file_path.parent()?;
760
761        loop {
762            if current.join("__init__.py").exists() {
763                let name = current.file_name().and_then(|n| n.to_str())?;
764                components.push(name.to_string());
765                match current.parent() {
766                    Some(parent) => current = parent,
767                    None => break,
768                }
769            } else {
770                break;
771            }
772        }
773
774        if components.is_empty() {
775            return None;
776        }
777
778        components.reverse();
779        Some(components.join("."))
780    }
781
782    /// Resolve a relative import (e.g. `from .models import X` where level=1,
783    /// module="models") to an absolute dotted module path string suitable for
784    /// use in any file (not just the fixture's package).
785    ///
786    /// Returns `None` when the path cannot be resolved (e.g. level goes above
787    /// the filesystem root).
788    fn resolve_relative_module_to_string(
789        &self,
790        module: &str,
791        level: usize,
792        fixture_file: &Path,
793    ) -> Option<String> {
794        // Navigate up `level` directories from the fixture file's own directory.
795        // level=1 means "current package" (.models), level=2 means "parent" (..models).
796        let mut base = fixture_file.parent()?;
797        for _ in 1..level {
798            base = base.parent()?;
799        }
800
801        // Build the theoretical target file path (may or may not exist on disk).
802        let target = if module.is_empty() {
803            // `from . import X` — target is the package __init__.py itself.
804            base.join("__init__.py")
805        } else {
806            // Replace dots in sub-module path with path separators.
807            let rel_path = module.replace('.', "/");
808            base.join(format!("{}.py", rel_path))
809        };
810
811        // Convert that file path to a dotted module path string.
812        Self::file_path_to_module_path(&target)
813    }
814
815    /// Build a map from imported name → `TypeImportSpec` for all import
816    /// statements in `stmts`.
817    ///
818    /// Unlike `extract_fixture_imports`, this function processes **all** imports
819    /// (including stdlib such as `pathlib` and `typing`) because type annotations
820    /// may reference any imported name.  Relative imports are resolved to their
821    /// absolute form so the resulting `import_statement` strings are valid in any
822    /// file, not just in the fixture's own package.
823    ///
824    /// Covers all four Python import styles:
825    ///
826    /// | Source statement                    | check_name  | import_statement               |
827    /// |-------------------------------------|-------------|-------------------------------|
828    /// | `import pathlib`                    | `"pathlib"` | `"import pathlib"`             |
829    /// | `import pathlib as pl`              | `"pl"`      | `"import pathlib as pl"`       |
830    /// | `from pathlib import Path`          | `"Path"`    | `"from pathlib import Path"`   |
831    /// | `from pathlib import Path as P`     | `"P"`       | `"from pathlib import Path as P"` |
832    pub(crate) fn build_name_to_import_map(
833        &self,
834        stmts: &[Stmt],
835        fixture_file: &Path,
836    ) -> HashMap<String, TypeImportSpec> {
837        let mut map = HashMap::new();
838
839        for stmt in stmts {
840            match stmt {
841                Stmt::Import(import_stmt) => {
842                    for alias in &import_stmt.names {
843                        let module = alias.name.to_string();
844                        let (check_name, import_statement) = if let Some(ref asname) = alias.asname
845                        {
846                            let asname_str = asname.to_string();
847                            (
848                                asname_str.clone(),
849                                format!("import {} as {}", module, asname_str),
850                            )
851                        } else {
852                            let top_level = module.split('.').next().unwrap_or(&module).to_string();
853                            (top_level, format!("import {}", module))
854                        };
855                        map.insert(
856                            check_name.clone(),
857                            TypeImportSpec {
858                                check_name,
859                                import_statement,
860                            },
861                        );
862                    }
863                }
864
865                Stmt::ImportFrom(import_from) => {
866                    let level = import_from
867                        .level
868                        .as_ref()
869                        .map(|l| l.to_usize())
870                        .unwrap_or(0);
871                    let raw_module = import_from
872                        .module
873                        .as_ref()
874                        .map(|m| m.to_string())
875                        .unwrap_or_default();
876
877                    // Resolve relative imports to absolute module paths.
878                    let abs_module = if level > 0 {
879                        match self.resolve_relative_module_to_string(
880                            &raw_module,
881                            level,
882                            fixture_file,
883                        ) {
884                            Some(m) => m,
885                            None => {
886                                debug!(
887                                    "Could not resolve relative import '.{}' from {:?}, skipping",
888                                    raw_module, fixture_file
889                                );
890                                continue;
891                            }
892                        }
893                    } else {
894                        raw_module
895                    };
896
897                    for alias in &import_from.names {
898                        if alias.name.as_str() == "*" {
899                            continue; // Star imports don't bind individual names here.
900                        }
901                        let name = alias.name.to_string();
902                        let (check_name, import_statement) = if let Some(ref asname) = alias.asname
903                        {
904                            let asname_str = asname.to_string();
905                            (
906                                asname_str.clone(),
907                                format!("from {} import {} as {}", abs_module, name, asname_str),
908                            )
909                        } else {
910                            (name.clone(), format!("from {} import {}", abs_module, name))
911                        };
912                        map.insert(
913                            check_name.clone(),
914                            TypeImportSpec {
915                                check_name,
916                                import_statement,
917                            },
918                        );
919                    }
920                }
921
922                _ => {}
923            }
924        }
925
926        map
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use std::fs;
934
935    /// Create a temp directory tree and return a guard that deletes it on drop.
936    struct TempDir(std::path::PathBuf);
937
938    impl TempDir {
939        fn new(name: &str) -> Self {
940            let path = std::env::temp_dir().join(name);
941            fs::create_dir_all(&path).unwrap();
942            Self(path)
943        }
944
945        fn path(&self) -> &std::path::Path {
946            &self.0
947        }
948    }
949
950    impl Drop for TempDir {
951        fn drop(&mut self) {
952            let _ = fs::remove_dir_all(&self.0);
953        }
954    }
955
956    // ── find_venv_python ───────────────────────────────────────────────────
957
958    /// Write an empty file at `path`, creating parent directories as needed.
959    fn touch(path: &std::path::Path) {
960        fs::create_dir_all(path.parent().unwrap()).unwrap();
961        fs::write(path, b"").unwrap();
962    }
963
964    #[test]
965    fn test_find_venv_python_unix_python3() {
966        let dir = TempDir::new("fvp_unix_py3");
967        touch(&dir.path().join("bin/python3"));
968        let result = find_venv_python(dir.path());
969        assert_eq!(result, Some(dir.path().join("bin/python3")));
970    }
971
972    #[test]
973    fn test_find_venv_python_unix_python_fallback() {
974        // Only `python` present (no `python3`).
975        let dir = TempDir::new("fvp_unix_py");
976        touch(&dir.path().join("bin/python"));
977        let result = find_venv_python(dir.path());
978        assert_eq!(result, Some(dir.path().join("bin/python")));
979    }
980
981    #[test]
982    fn test_find_venv_python_unix_prefers_python3_over_python() {
983        let dir = TempDir::new("fvp_unix_prefer");
984        touch(&dir.path().join("bin/python3"));
985        touch(&dir.path().join("bin/python"));
986        let result = find_venv_python(dir.path());
987        assert_eq!(
988            result,
989            Some(dir.path().join("bin/python3")),
990            "python3 should be preferred over python"
991        );
992    }
993
994    #[test]
995    fn test_find_venv_python_windows_style() {
996        let dir = TempDir::new("fvp_win_py");
997        touch(&dir.path().join("Scripts/python.exe"));
998        let result = find_venv_python(dir.path());
999        assert_eq!(result, Some(dir.path().join("Scripts/python.exe")));
1000    }
1001
1002    #[test]
1003    fn test_find_venv_python_windows_prefers_python3_exe() {
1004        let dir = TempDir::new("fvp_win_prefer");
1005        touch(&dir.path().join("Scripts/python3.exe"));
1006        touch(&dir.path().join("Scripts/python.exe"));
1007        let result = find_venv_python(dir.path());
1008        assert_eq!(
1009            result,
1010            Some(dir.path().join("Scripts/python3.exe")),
1011            "python3.exe should be preferred over python.exe"
1012        );
1013    }
1014
1015    #[test]
1016    fn test_find_venv_python_not_found() {
1017        let dir = TempDir::new("fvp_empty");
1018        assert_eq!(find_venv_python(dir.path()), None);
1019    }
1020
1021    #[test]
1022    fn test_find_venv_python_wrong_layout() {
1023        // Python binary at the venv root — not in bin/ or Scripts/.
1024        let dir = TempDir::new("fvp_wrong_layout");
1025        touch(&dir.path().join("python3"));
1026        assert_eq!(find_venv_python(dir.path()), None);
1027    }
1028
1029    #[test]
1030    fn test_try_init_stdlib_no_python_returns_false_or_already_set() {
1031        // An empty venv directory has no Python binary → should return false
1032        // without panicking.  If RUNTIME_STDLIB_MODULES was already populated
1033        // by a prior test (OnceLock is set once per process) the function
1034        // returns true; either way is_stdlib_module must remain correct.
1035        let dir = TempDir::new("fvp_no_python");
1036        let _ = try_init_stdlib_from_python(dir.path());
1037        assert!(is_stdlib_module("os"), "os must always be stdlib");
1038        assert!(is_stdlib_module("sys"), "sys must always be stdlib");
1039        assert!(!is_stdlib_module("pytest"), "pytest is not stdlib");
1040        assert!(!is_stdlib_module("flask"), "flask is not stdlib");
1041    }
1042
1043    // ── file_path_to_module_path ────────────────────────────────────────────
1044
1045    #[test]
1046    fn test_module_path_regular_file_no_package() {
1047        // File in a plain directory (no __init__.py) → just the stem.
1048        let dir = TempDir::new("fptmp_plain");
1049        let file = dir.path().join("conftest.py");
1050        fs::write(&file, "").unwrap();
1051        // No __init__.py in the directory, so the result is just "conftest".
1052        assert_eq!(
1053            FixtureDatabase::file_path_to_module_path(&file),
1054            Some("conftest".to_string())
1055        );
1056    }
1057
1058    #[test]
1059    fn test_module_path_regular_file_in_package() {
1060        // pkg/__init__.py exists → file inside pkg resolves to "pkg.module".
1061        let dir = TempDir::new("fptmp_pkg");
1062        let pkg = dir.path().join("pkg");
1063        fs::create_dir_all(&pkg).unwrap();
1064        fs::write(pkg.join("__init__.py"), "").unwrap();
1065        let file = pkg.join("module.py");
1066        fs::write(&file, "").unwrap();
1067        assert_eq!(
1068            FixtureDatabase::file_path_to_module_path(&file),
1069            Some("pkg.module".to_string())
1070        );
1071    }
1072
1073    #[test]
1074    fn test_module_path_init_file_is_package_root() {
1075        // pkg/__init__.py itself → resolves to "pkg", NOT "pkg.__init__".
1076        // This is the regression test for the `from . import X` bug fix.
1077        let dir = TempDir::new("fptmp_init");
1078        let pkg = dir.path().join("pkg");
1079        fs::create_dir_all(&pkg).unwrap();
1080        let init = pkg.join("__init__.py");
1081        fs::write(&init, "").unwrap();
1082        assert_eq!(
1083            FixtureDatabase::file_path_to_module_path(&init),
1084            Some("pkg".to_string())
1085        );
1086    }
1087
1088    #[test]
1089    fn test_module_path_nested_init_file() {
1090        // pkg/sub/__init__.py → resolves to "pkg.sub", NOT "pkg.sub.__init__".
1091        let dir = TempDir::new("fptmp_nested_init");
1092        let pkg = dir.path().join("pkg");
1093        let sub = pkg.join("sub");
1094        fs::create_dir_all(&sub).unwrap();
1095        fs::write(pkg.join("__init__.py"), "").unwrap();
1096        let init = sub.join("__init__.py");
1097        fs::write(&init, "").unwrap();
1098        assert_eq!(
1099            FixtureDatabase::file_path_to_module_path(&init),
1100            Some("pkg.sub".to_string())
1101        );
1102    }
1103
1104    #[test]
1105    fn test_module_path_nested_package() {
1106        // pkg/sub/module.py with both __init__.py files → "pkg.sub.module".
1107        let dir = TempDir::new("fptmp_nested");
1108        let pkg = dir.path().join("pkg");
1109        let sub = pkg.join("sub");
1110        fs::create_dir_all(&sub).unwrap();
1111        fs::write(pkg.join("__init__.py"), "").unwrap();
1112        fs::write(sub.join("__init__.py"), "").unwrap();
1113        let file = sub.join("module.py");
1114        fs::write(&file, "").unwrap();
1115        assert_eq!(
1116            FixtureDatabase::file_path_to_module_path(&file),
1117            Some("pkg.sub.module".to_string())
1118        );
1119    }
1120
1121    #[test]
1122    fn test_module_path_conftest_in_package() {
1123        // pkg/conftest.py → "pkg.conftest".
1124        let dir = TempDir::new("fptmp_conftest_pkg");
1125        let pkg = dir.path().join("mypkg");
1126        fs::create_dir_all(&pkg).unwrap();
1127        fs::write(pkg.join("__init__.py"), "").unwrap();
1128        let file = pkg.join("conftest.py");
1129        fs::write(&file, "").unwrap();
1130        assert_eq!(
1131            FixtureDatabase::file_path_to_module_path(&file),
1132            Some("mypkg.conftest".to_string())
1133        );
1134    }
1135
1136    // ── build_name_to_import_map / get_name_to_import_map ─────────────────
1137    //
1138    // These tests exercise the import-map key used for `import X.Y` (bare
1139    // dotted imports without an alias).  Python binds only the top-level name
1140    // in the local namespace (`import collections.abc` → name `collections`),
1141    // so the map key must be the top-level component, not the full dotted path.
1142
1143    #[test]
1144    fn test_build_map_dotted_import_keyed_by_top_level() {
1145        // `import collections.abc` without alias: the bound name in Python is
1146        // "collections", so the map key must be "collections" — NOT the full
1147        // dotted path "collections.abc".  The import_statement must preserve
1148        // the full dotted path for correct insertion in consumer files.
1149        let db = FixtureDatabase::new();
1150        let map = db.get_name_to_import_map(
1151            &PathBuf::from("/tmp/test_bm_dotted.py"),
1152            "import collections.abc\n",
1153        );
1154        let spec = map
1155            .get("collections")
1156            .expect("key 'collections' must be present");
1157        assert_eq!(spec.check_name, "collections");
1158        assert_eq!(spec.import_statement, "import collections.abc");
1159        assert!(
1160            !map.contains_key("collections.abc"),
1161            "full dotted path must not be a key; only the top-level bound name is"
1162        );
1163    }
1164
1165    #[test]
1166    fn test_build_map_two_level_dotted_import_keyed_by_top_level() {
1167        // `import xml.etree.ElementTree` — three components; bound name is "xml".
1168        // The map key must be "xml" and import_statement the full dotted path.
1169        let db = FixtureDatabase::new();
1170        let map = db.get_name_to_import_map(
1171            &PathBuf::from("/tmp/test_bm_two_level.py"),
1172            "import xml.etree.ElementTree\n",
1173        );
1174        let spec = map.get("xml").expect("key 'xml' must be present");
1175        assert_eq!(spec.check_name, "xml");
1176        assert_eq!(spec.import_statement, "import xml.etree.ElementTree");
1177        assert!(
1178            !map.contains_key("xml.etree.ElementTree"),
1179            "full dotted path must not be a key"
1180        );
1181        assert!(
1182            !map.contains_key("xml.etree"),
1183            "partial dotted path must not be a key"
1184        );
1185    }
1186
1187    #[test]
1188    fn test_build_map_simple_import_unaffected() {
1189        // `import pathlib` — single component; fix must not change behaviour for
1190        // module names that contain no dots.
1191        let db = FixtureDatabase::new();
1192        let map =
1193            db.get_name_to_import_map(&PathBuf::from("/tmp/test_bm_simple.py"), "import pathlib\n");
1194        let spec = map.get("pathlib").expect("key 'pathlib' must be present");
1195        assert_eq!(spec.check_name, "pathlib");
1196        assert_eq!(spec.import_statement, "import pathlib");
1197    }
1198
1199    #[test]
1200    fn test_build_map_aliased_dotted_import_unaffected() {
1201        // `import collections.abc as abc_mod` — aliased: check_name is the alias,
1202        // not the top-level module name.  The fix only touches the non-aliased branch.
1203        let db = FixtureDatabase::new();
1204        let map = db.get_name_to_import_map(
1205            &PathBuf::from("/tmp/test_bm_aliased.py"),
1206            "import collections.abc as abc_mod\n",
1207        );
1208        let spec = map.get("abc_mod").expect("key 'abc_mod' must be present");
1209        assert_eq!(spec.check_name, "abc_mod");
1210        assert_eq!(spec.import_statement, "import collections.abc as abc_mod");
1211        assert!(
1212            !map.contains_key("collections"),
1213            "top-level name must not be keyed when alias present"
1214        );
1215        assert!(
1216            !map.contains_key("collections.abc"),
1217            "dotted path must not be keyed when alias present"
1218        );
1219    }
1220}