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::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
18/// Static HashSet of standard library module names for O(1) lookup.
19static 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/// Represents a fixture import in a Python file.
107#[derive(Debug, Clone)]
108#[allow(dead_code)] // Fields used for debugging and potential future features
109pub struct FixtureImport {
110    /// The module path being imported from (e.g., ".pytest_fixtures" or "pytest_fixtures")
111    pub module_path: String,
112    /// Whether this is a star import (`from X import *`)
113    pub is_star_import: bool,
114    /// Specific names imported (empty for star imports)
115    pub imported_names: Vec<String>,
116    /// The file that contains this import
117    pub importing_file: PathBuf,
118    /// Line number of the import statement
119    pub line: usize,
120}
121
122impl FixtureDatabase {
123    /// Extract fixture imports from a module's statements.
124    /// Returns a list of imports that could potentially bring in fixtures.
125    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                // Skip imports from standard library or well-known non-fixture modules
136                let mut module = import_from
137                    .module
138                    .as_ref()
139                    .map(|m| m.to_string())
140                    .unwrap_or_default();
141
142                // Add leading dots for relative imports
143                // level indicates how many parent directories to go up:
144                // level=1 means "from . import" (current package)
145                // level=2 means "from .. import" (parent package)
146                if let Some(ref level) = import_from.level {
147                    let dots = ".".repeat(level.to_usize());
148                    module = dots + &module;
149                }
150
151                // Skip obvious non-fixture imports
152                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                // Check if this is a star import
160                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                    // Collect specific imported names
175                    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    /// Extract module paths from `pytest_plugins` variable assignments.
198    ///
199    /// Handles both regular and annotated assignments:
200    /// - `pytest_plugins = "module"` (single string)
201    /// - `pytest_plugins = ["module_a", "module_b"]` (list)
202    /// - `pytest_plugins = ("module_a", "module_b")` (tuple)
203    /// - `pytest_plugins: list[str] = ["module_a"]` (annotated)
204    ///
205    /// If multiple assignments exist, only the last one is used (matching pytest semantics).
206    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            // Last assignment wins: clear previous values
237            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    /// Check if a module is a standard library module that can't contain fixtures.
273    /// Uses a static HashSet for O(1) lookup instead of linear array search.
274    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    /// Resolve a module path to a file path.
280    /// Handles both relative imports (starting with .) and absolute imports.
281    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            // Relative import
295            self.resolve_relative_import(module_path, parent_dir)
296        } else {
297            // Absolute import - search in the same directory tree
298            self.resolve_absolute_import(module_path, parent_dir)
299        }
300    }
301
302    /// Resolve a relative import like `.pytest_fixtures` or `..utils`.
303    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        // Count leading dots to determine how many directories to go up
308        while chars.peek() == Some(&'.') {
309            chars.next();
310            if chars.peek() != Some(&'.') {
311                // Single dot - stay in current directory
312                break;
313            }
314            // Additional dots - go up one directory
315            current_dir = current_dir.parent()?.to_path_buf();
316        }
317
318        let remaining: String = chars.collect();
319        if remaining.is_empty() {
320            // Import from __init__.py of current/parent package
321            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, &current_dir)
329    }
330
331    /// Resolve an absolute import by searching up the directory tree,
332    /// then falling back to site-packages paths for venv plugin modules.
333    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, &current_dir) {
338                return Some(path);
339            }
340
341            // Go up one directory
342            match current_dir.parent() {
343                Some(parent) => current_dir = parent.to_path_buf(),
344                None => break,
345            }
346        }
347
348        // Fallback: search in site-packages paths (for venv plugin pytest_plugins)
349        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        // Fallback: search in editable install source roots
356        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    /// Find a module file given a dotted path and base directory.
366    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                // Last part - could be a module file or a package
375                let py_file = current_path.join(format!("{}.py", part));
376                if py_file.exists() {
377                    return Some(py_file);
378                }
379
380                // Also check if the file is in the cache (for test files that don't exist on disk)
381                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                // Check if it's a package with __init__.py
387                let package_init = current_path.join(part).join("__init__.py");
388                if package_init.exists() {
389                    return Some(package_init);
390                }
391
392                // Also check if the package __init__.py is in the cache
393                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                // Not the last part - must be a directory
399                current_path = current_path.join(part);
400                if !current_path.is_dir() {
401                    return None;
402                }
403            }
404        }
405
406        None
407    }
408
409    /// Get fixtures that are re-exported from a file via imports.
410    /// This handles `from .module import *` patterns that bring fixtures into scope.
411    ///
412    /// Results are cached with content-hash and definitions-version based invalidation.
413    /// Returns fixture names that are available in `file_path` via imports.
414    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        // Prevent circular imports
422        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        // Get the file content first (needed for cache validation)
429        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        // Check cache - valid if both content hash and definitions version match
439        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        // Compute imported fixtures
448        let imported_fixtures = self.compute_imported_fixtures(&canonical_path, &content, visited);
449
450        // Store in cache
451        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    /// Internal method to compute imported fixtures without caching.
471    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                // Resolve the import to a file path
490                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                    // Star import: get all fixtures from the resolved file
509                    // First, get fixtures defined directly in that file
510                    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                    // Also recursively get fixtures imported into that file
517                    let transitive = self.get_imported_fixtures(&resolved_canonical, visited);
518                    imported_fixtures.extend(transitive);
519                } else {
520                    // Explicit import: only include the specified names if they are fixtures
521                    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            // Process pytest_plugins variable (treated like star imports)
530            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    /// Check if a fixture is available in a file via imports.
563    /// This is used in resolution to check conftest.py files that import fixtures.
564    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}