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 imports;
14mod resolver;
15mod scanner;
16pub(crate) mod string_utils; // pub(crate) for inlay_hint provider access
17pub mod types;
18mod undeclared;
19
20#[allow(unused_imports)] // ParamInsertionInfo re-exported for public API via lib.rs
21pub use types::{
22    CompletionContext, FixtureCycle, FixtureDefinition, FixtureScope, FixtureUsage,
23    ParamInsertionInfo, ScopeMismatch, UndeclaredFixture,
24};
25
26use dashmap::DashMap;
27use std::collections::hash_map::DefaultHasher;
28use std::collections::HashSet;
29use std::hash::{Hash, Hasher};
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32use tracing::debug;
33
34/// Cache entry for line indices: (content_hash, line_index).
35/// The content hash is used to invalidate the cache when file content changes.
36type LineIndexCacheEntry = (u64, Arc<Vec<usize>>);
37
38/// Cache entry for parsed AST: (content_hash, ast).
39/// The content hash is used to invalidate the cache when file content changes.
40type AstCacheEntry = (u64, Arc<rustpython_parser::ast::Mod>);
41
42/// Cache entry for fixture cycles: (definitions_version, cycles).
43/// The version is incremented when definitions change to invalidate the cache.
44type CycleCacheEntry = (u64, Arc<Vec<types::FixtureCycle>>);
45
46/// Cache entry for available fixtures: (definitions_version, fixtures).
47/// The version is incremented when definitions change to invalidate the cache.
48type AvailableFixturesCacheEntry = (u64, Arc<Vec<FixtureDefinition>>);
49
50/// Cache entry for imported fixtures: (content_hash, definitions_version, imported_fixture_names).
51/// Invalidated when either the file content or fixture definitions change.
52type ImportedFixturesCacheEntry = (u64, u64, Arc<HashSet<String>>);
53
54/// The central database for fixture definitions and usages.
55///
56/// Uses `DashMap` for lock-free concurrent access during workspace scanning.
57#[derive(Debug)]
58pub struct FixtureDatabase {
59    /// Map from fixture name to all its definitions (can be in multiple conftest.py files).
60    pub definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
61    /// Reverse index: file path -> fixture names defined in that file.
62    /// Used for efficient cleanup when a file is re-analyzed.
63    pub file_definitions: Arc<DashMap<PathBuf, HashSet<String>>>,
64    /// Map from file path to fixtures used in that file.
65    pub usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
66    /// Reverse index: fixture name -> (file_path, usage) pairs.
67    /// Used for efficient O(1) lookup in find_references_for_definition.
68    pub usage_by_fixture: Arc<DashMap<String, Vec<(PathBuf, FixtureUsage)>>>,
69    /// Cache of file contents for analyzed files (uses Arc for efficient sharing).
70    pub file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
71    /// Map from file path to undeclared fixtures used in function bodies.
72    pub undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
73    /// Map from file path to imported names in that file.
74    pub imports: Arc<DashMap<PathBuf, HashSet<String>>>,
75    /// Cache of canonical paths to avoid repeated filesystem calls.
76    pub canonical_path_cache: Arc<DashMap<PathBuf, PathBuf>>,
77    /// Cache of line indices (byte offsets) for files to avoid recomputation.
78    /// Stores (content_hash, line_index) to invalidate when content changes.
79    pub line_index_cache: Arc<DashMap<PathBuf, LineIndexCacheEntry>>,
80    /// Cache of parsed AST for files to avoid re-parsing.
81    /// Stores (content_hash, ast) to invalidate when content changes.
82    pub ast_cache: Arc<DashMap<PathBuf, AstCacheEntry>>,
83    /// Version counter for definitions, incremented on each change.
84    /// Used to invalidate cycle detection cache and available fixtures cache.
85    pub definitions_version: Arc<std::sync::atomic::AtomicU64>,
86    /// Cache of detected fixture cycles.
87    /// Stores (definitions_version, cycles) to invalidate when definitions change.
88    pub cycle_cache: Arc<DashMap<(), CycleCacheEntry>>,
89    /// Cache of available fixtures per file.
90    /// Stores (definitions_version, fixtures) to invalidate when definitions change.
91    pub available_fixtures_cache: Arc<DashMap<PathBuf, AvailableFixturesCacheEntry>>,
92    /// Cache of imported fixtures per file.
93    /// Stores (content_hash, definitions_version, fixture_names) for invalidation.
94    pub imported_fixtures_cache: Arc<DashMap<PathBuf, ImportedFixturesCacheEntry>>,
95}
96
97impl Default for FixtureDatabase {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl FixtureDatabase {
104    /// Create a new empty fixture database.
105    pub fn new() -> Self {
106        Self {
107            definitions: Arc::new(DashMap::new()),
108            file_definitions: Arc::new(DashMap::new()),
109            usages: Arc::new(DashMap::new()),
110            usage_by_fixture: Arc::new(DashMap::new()),
111            file_cache: Arc::new(DashMap::new()),
112            undeclared_fixtures: Arc::new(DashMap::new()),
113            imports: Arc::new(DashMap::new()),
114            canonical_path_cache: Arc::new(DashMap::new()),
115            line_index_cache: Arc::new(DashMap::new()),
116            ast_cache: Arc::new(DashMap::new()),
117            definitions_version: Arc::new(std::sync::atomic::AtomicU64::new(0)),
118            cycle_cache: Arc::new(DashMap::new()),
119            available_fixtures_cache: Arc::new(DashMap::new()),
120            imported_fixtures_cache: Arc::new(DashMap::new()),
121        }
122    }
123
124    /// Increment the definitions version to invalidate cycle cache.
125    /// Called whenever fixture definitions are modified.
126    pub(crate) fn invalidate_cycle_cache(&self) {
127        self.definitions_version
128            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
129    }
130
131    /// Get canonical path with caching to avoid repeated filesystem calls.
132    /// Falls back to original path if canonicalization fails.
133    pub(crate) fn get_canonical_path(&self, path: PathBuf) -> PathBuf {
134        // Check cache first
135        if let Some(cached) = self.canonical_path_cache.get(&path) {
136            return cached.value().clone();
137        }
138
139        // Attempt canonicalization
140        let canonical = path.canonicalize().unwrap_or_else(|_| {
141            debug!("Could not canonicalize path {:?}, using as-is", path);
142            path.clone()
143        });
144
145        // Store in cache for future lookups
146        self.canonical_path_cache.insert(path, canonical.clone());
147        canonical
148    }
149
150    /// Get file content from cache or read from filesystem.
151    /// Returns None if file cannot be read.
152    pub(crate) fn get_file_content(&self, file_path: &Path) -> Option<Arc<String>> {
153        if let Some(cached) = self.file_cache.get(file_path) {
154            Some(Arc::clone(cached.value()))
155        } else {
156            std::fs::read_to_string(file_path).ok().map(Arc::new)
157        }
158    }
159
160    /// Get or compute line index for a file, with content-hash-based caching.
161    /// Returns Arc to avoid cloning the potentially large Vec.
162    /// The cache is invalidated when the content hash changes.
163    pub(crate) fn get_line_index(&self, file_path: &Path, content: &str) -> Arc<Vec<usize>> {
164        let content_hash = Self::hash_content(content);
165
166        // Check cache first - only use if content hash matches
167        if let Some(cached) = self.line_index_cache.get(file_path) {
168            let (cached_hash, cached_index) = cached.value();
169            if *cached_hash == content_hash {
170                return Arc::clone(cached_index);
171            }
172        }
173
174        // Build line index
175        let line_index = Self::build_line_index(content);
176        let arc_index = Arc::new(line_index);
177
178        // Store in cache with content hash
179        self.line_index_cache.insert(
180            file_path.to_path_buf(),
181            (content_hash, Arc::clone(&arc_index)),
182        );
183
184        arc_index
185    }
186
187    /// Get or parse AST for a file, with content-hash-based caching.
188    /// Returns Arc to avoid cloning the potentially large AST.
189    /// The cache is invalidated when the content hash changes.
190    pub(crate) fn get_parsed_ast(
191        &self,
192        file_path: &Path,
193        content: &str,
194    ) -> Option<Arc<rustpython_parser::ast::Mod>> {
195        let content_hash = Self::hash_content(content);
196
197        // Check cache first - only use if content hash matches
198        if let Some(cached) = self.ast_cache.get(file_path) {
199            let (cached_hash, cached_ast) = cached.value();
200            if *cached_hash == content_hash {
201                return Some(Arc::clone(cached_ast));
202            }
203        }
204
205        // Parse the content
206        let parsed = rustpython_parser::parse(content, rustpython_parser::Mode::Module, "").ok()?;
207        let arc_ast = Arc::new(parsed);
208
209        // Store in cache with content hash
210        self.ast_cache.insert(
211            file_path.to_path_buf(),
212            (content_hash, Arc::clone(&arc_ast)),
213        );
214
215        Some(arc_ast)
216    }
217
218    /// Compute a hash of the content for cache invalidation.
219    fn hash_content(content: &str) -> u64 {
220        let mut hasher = DefaultHasher::new();
221        content.hash(&mut hasher);
222        hasher.finish()
223    }
224
225    /// Remove all cached data for a file.
226    /// Called when a file is closed or deleted to prevent unbounded memory growth.
227    pub fn cleanup_file_cache(&self, file_path: &Path) {
228        // Use canonical path for consistent cleanup
229        let canonical = file_path
230            .canonicalize()
231            .unwrap_or_else(|_| file_path.to_path_buf());
232
233        debug!("Cleaning up cache for file: {:?}", canonical);
234
235        // Remove from line_index_cache
236        self.line_index_cache.remove(&canonical);
237
238        // Remove from ast_cache
239        self.ast_cache.remove(&canonical);
240
241        // Remove from file_cache
242        self.file_cache.remove(&canonical);
243
244        // Remove from available_fixtures_cache (this file's cached available fixtures)
245        self.available_fixtures_cache.remove(&canonical);
246
247        // Remove from imported_fixtures_cache
248        self.imported_fixtures_cache.remove(&canonical);
249
250        // Note: We don't remove from canonical_path_cache because:
251        // 1. It's keyed by original path, not canonical path
252        // 2. Path->canonical mappings are stable and small
253        // 3. They may be needed again if file is reopened
254
255        // Note: We don't remove definitions/usages here because:
256        // 1. They might be needed for cross-file references
257        // 2. They're cleaned up on next analyze_file call anyway
258    }
259}