pytest_language_server/fixtures/
mod.rs

1//! Fixture database and analysis module.
2//!
3//! This module provides the core functionality for managing pytest fixtures:
4//! - Scanning workspaces for fixture definitions
5//! - Analyzing Python files for fixtures and their usages
6//! - Resolving fixture definitions based on pytest's priority rules
7//! - Providing completion context for fixture suggestions
8
9mod analyzer;
10pub(crate) mod cli;
11pub mod decorators; // Public for testing
12mod docstring;
13mod resolver;
14mod scanner;
15mod string_utils;
16pub mod types;
17mod undeclared;
18
19#[allow(unused_imports)] // ParamInsertionInfo re-exported for public API via lib.rs
20pub use types::{
21    CompletionContext, FixtureDefinition, FixtureUsage, ParamInsertionInfo, UndeclaredFixture,
22};
23
24use dashmap::DashMap;
25use std::collections::hash_map::DefaultHasher;
26use std::collections::HashSet;
27use std::hash::{Hash, Hasher};
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30use tracing::debug;
31
32/// Cache entry for line indices: (content_hash, line_index).
33/// The content hash is used to invalidate the cache when file content changes.
34type LineIndexCacheEntry = (u64, Arc<Vec<usize>>);
35
36/// Cache entry for parsed AST: (content_hash, ast).
37/// The content hash is used to invalidate the cache when file content changes.
38type AstCacheEntry = (u64, Arc<rustpython_parser::ast::Mod>);
39
40/// The central database for fixture definitions and usages.
41///
42/// Uses `DashMap` for lock-free concurrent access during workspace scanning.
43#[derive(Debug)]
44pub struct FixtureDatabase {
45    /// Map from fixture name to all its definitions (can be in multiple conftest.py files).
46    pub definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
47    /// Reverse index: file path -> fixture names defined in that file.
48    /// Used for efficient cleanup when a file is re-analyzed.
49    pub file_definitions: Arc<DashMap<PathBuf, HashSet<String>>>,
50    /// Map from file path to fixtures used in that file.
51    pub usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
52    /// Cache of file contents for analyzed files (uses Arc for efficient sharing).
53    pub file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
54    /// Map from file path to undeclared fixtures used in function bodies.
55    pub undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
56    /// Map from file path to imported names in that file.
57    pub imports: Arc<DashMap<PathBuf, HashSet<String>>>,
58    /// Cache of canonical paths to avoid repeated filesystem calls.
59    pub canonical_path_cache: Arc<DashMap<PathBuf, PathBuf>>,
60    /// Cache of line indices (byte offsets) for files to avoid recomputation.
61    /// Stores (content_hash, line_index) to invalidate when content changes.
62    pub line_index_cache: Arc<DashMap<PathBuf, LineIndexCacheEntry>>,
63    /// Cache of parsed AST for files to avoid re-parsing.
64    /// Stores (content_hash, ast) to invalidate when content changes.
65    pub ast_cache: Arc<DashMap<PathBuf, AstCacheEntry>>,
66}
67
68impl Default for FixtureDatabase {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl FixtureDatabase {
75    /// Create a new empty fixture database.
76    pub fn new() -> Self {
77        Self {
78            definitions: Arc::new(DashMap::new()),
79            file_definitions: Arc::new(DashMap::new()),
80            usages: Arc::new(DashMap::new()),
81            file_cache: Arc::new(DashMap::new()),
82            undeclared_fixtures: Arc::new(DashMap::new()),
83            imports: Arc::new(DashMap::new()),
84            canonical_path_cache: Arc::new(DashMap::new()),
85            line_index_cache: Arc::new(DashMap::new()),
86            ast_cache: Arc::new(DashMap::new()),
87        }
88    }
89
90    /// Get canonical path with caching to avoid repeated filesystem calls.
91    /// Falls back to original path if canonicalization fails.
92    pub(crate) fn get_canonical_path(&self, path: PathBuf) -> PathBuf {
93        // Check cache first
94        if let Some(cached) = self.canonical_path_cache.get(&path) {
95            return cached.value().clone();
96        }
97
98        // Attempt canonicalization
99        let canonical = path.canonicalize().unwrap_or_else(|_| {
100            debug!("Could not canonicalize path {:?}, using as-is", path);
101            path.clone()
102        });
103
104        // Store in cache for future lookups
105        self.canonical_path_cache.insert(path, canonical.clone());
106        canonical
107    }
108
109    /// Get file content from cache or read from filesystem.
110    /// Returns None if file cannot be read.
111    pub(crate) fn get_file_content(&self, file_path: &Path) -> Option<Arc<String>> {
112        if let Some(cached) = self.file_cache.get(file_path) {
113            Some(Arc::clone(cached.value()))
114        } else {
115            std::fs::read_to_string(file_path).ok().map(Arc::new)
116        }
117    }
118
119    /// Get or compute line index for a file, with content-hash-based caching.
120    /// Returns Arc to avoid cloning the potentially large Vec.
121    /// The cache is invalidated when the content hash changes.
122    pub(crate) fn get_line_index(&self, file_path: &Path, content: &str) -> Arc<Vec<usize>> {
123        let content_hash = Self::hash_content(content);
124
125        // Check cache first - only use if content hash matches
126        if let Some(cached) = self.line_index_cache.get(file_path) {
127            let (cached_hash, cached_index) = cached.value();
128            if *cached_hash == content_hash {
129                return Arc::clone(cached_index);
130            }
131        }
132
133        // Build line index
134        let line_index = Self::build_line_index(content);
135        let arc_index = Arc::new(line_index);
136
137        // Store in cache with content hash
138        self.line_index_cache.insert(
139            file_path.to_path_buf(),
140            (content_hash, Arc::clone(&arc_index)),
141        );
142
143        arc_index
144    }
145
146    /// Get or parse AST for a file, with content-hash-based caching.
147    /// Returns Arc to avoid cloning the potentially large AST.
148    /// The cache is invalidated when the content hash changes.
149    pub(crate) fn get_parsed_ast(
150        &self,
151        file_path: &Path,
152        content: &str,
153    ) -> Option<Arc<rustpython_parser::ast::Mod>> {
154        let content_hash = Self::hash_content(content);
155
156        // Check cache first - only use if content hash matches
157        if let Some(cached) = self.ast_cache.get(file_path) {
158            let (cached_hash, cached_ast) = cached.value();
159            if *cached_hash == content_hash {
160                return Some(Arc::clone(cached_ast));
161            }
162        }
163
164        // Parse the content
165        let parsed = rustpython_parser::parse(content, rustpython_parser::Mode::Module, "").ok()?;
166        let arc_ast = Arc::new(parsed);
167
168        // Store in cache with content hash
169        self.ast_cache.insert(
170            file_path.to_path_buf(),
171            (content_hash, Arc::clone(&arc_ast)),
172        );
173
174        Some(arc_ast)
175    }
176
177    /// Compute a hash of the content for cache invalidation.
178    fn hash_content(content: &str) -> u64 {
179        let mut hasher = DefaultHasher::new();
180        content.hash(&mut hasher);
181        hasher.finish()
182    }
183
184    /// Remove all cached data for a file.
185    /// Called when a file is closed or deleted to prevent unbounded cache growth.
186    pub fn cleanup_file_cache(&self, file_path: &Path) {
187        // Use canonical path for consistent cleanup
188        let canonical = file_path
189            .canonicalize()
190            .unwrap_or_else(|_| file_path.to_path_buf());
191
192        debug!("Cleaning up cache for file: {:?}", canonical);
193
194        // Remove from line_index_cache
195        self.line_index_cache.remove(&canonical);
196
197        // Remove from ast_cache
198        self.ast_cache.remove(&canonical);
199
200        // Remove from file_cache
201        self.file_cache.remove(&canonical);
202
203        // Note: We don't remove from canonical_path_cache because:
204        // 1. It's keyed by original path, not canonical path
205        // 2. Path->canonical mappings are stable and small
206        // 3. They may be needed again if file is reopened
207
208        // Note: We don't remove definitions/usages here because:
209        // 1. They might be needed for cross-file references
210        // 2. They're cleaned up on next analyze_file call anyway
211    }
212}