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/// Maximum number of files to keep in the file content cache.
55/// When exceeded, the oldest entries are evicted to prevent unbounded memory growth.
56const MAX_FILE_CACHE_SIZE: usize = 2000;
57
58/// The central database for fixture definitions and usages.
59///
60/// Uses `DashMap` for lock-free concurrent access during workspace scanning.
61#[derive(Debug)]
62pub struct FixtureDatabase {
63    /// Map from fixture name to all its definitions (can be in multiple conftest.py files).
64    pub definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
65    /// Reverse index: file path -> fixture names defined in that file.
66    /// Used for efficient cleanup when a file is re-analyzed.
67    pub file_definitions: Arc<DashMap<PathBuf, HashSet<String>>>,
68    /// Map from file path to fixtures used in that file.
69    pub usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
70    /// Reverse index: fixture name -> (file_path, usage) pairs.
71    /// Used for efficient O(1) lookup in find_references_for_definition.
72    pub usage_by_fixture: Arc<DashMap<String, Vec<(PathBuf, FixtureUsage)>>>,
73    /// Cache of file contents for analyzed files (uses Arc for efficient sharing).
74    pub file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
75    /// Map from file path to undeclared fixtures used in function bodies.
76    pub undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
77    /// Map from file path to imported names in that file.
78    pub imports: Arc<DashMap<PathBuf, HashSet<String>>>,
79    /// Cache of canonical paths to avoid repeated filesystem calls.
80    pub canonical_path_cache: Arc<DashMap<PathBuf, PathBuf>>,
81    /// Cache of line indices (byte offsets) for files to avoid recomputation.
82    /// Stores (content_hash, line_index) to invalidate when content changes.
83    pub line_index_cache: Arc<DashMap<PathBuf, LineIndexCacheEntry>>,
84    /// Cache of parsed AST for files to avoid re-parsing.
85    /// Stores (content_hash, ast) to invalidate when content changes.
86    pub ast_cache: Arc<DashMap<PathBuf, AstCacheEntry>>,
87    /// Version counter for definitions, incremented on each change.
88    /// Used to invalidate cycle detection cache and available fixtures cache.
89    pub definitions_version: Arc<std::sync::atomic::AtomicU64>,
90    /// Cache of detected fixture cycles.
91    /// Stores (definitions_version, cycles) to invalidate when definitions change.
92    pub cycle_cache: Arc<DashMap<(), CycleCacheEntry>>,
93    /// Cache of available fixtures per file.
94    /// Stores (definitions_version, fixtures) to invalidate when definitions change.
95    pub available_fixtures_cache: Arc<DashMap<PathBuf, AvailableFixturesCacheEntry>>,
96    /// Cache of imported fixtures per file.
97    /// Stores (content_hash, definitions_version, fixture_names) for invalidation.
98    pub imported_fixtures_cache: Arc<DashMap<PathBuf, ImportedFixturesCacheEntry>>,
99}
100
101impl Default for FixtureDatabase {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl FixtureDatabase {
108    /// Create a new empty fixture database.
109    pub fn new() -> Self {
110        Self {
111            definitions: Arc::new(DashMap::new()),
112            file_definitions: Arc::new(DashMap::new()),
113            usages: Arc::new(DashMap::new()),
114            usage_by_fixture: Arc::new(DashMap::new()),
115            file_cache: Arc::new(DashMap::new()),
116            undeclared_fixtures: Arc::new(DashMap::new()),
117            imports: Arc::new(DashMap::new()),
118            canonical_path_cache: Arc::new(DashMap::new()),
119            line_index_cache: Arc::new(DashMap::new()),
120            ast_cache: Arc::new(DashMap::new()),
121            definitions_version: Arc::new(std::sync::atomic::AtomicU64::new(0)),
122            cycle_cache: Arc::new(DashMap::new()),
123            available_fixtures_cache: Arc::new(DashMap::new()),
124            imported_fixtures_cache: Arc::new(DashMap::new()),
125        }
126    }
127
128    /// Increment the definitions version to invalidate cycle cache.
129    /// Called whenever fixture definitions are modified.
130    pub(crate) fn invalidate_cycle_cache(&self) {
131        self.definitions_version
132            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
133    }
134
135    /// Get canonical path with caching to avoid repeated filesystem calls.
136    /// Falls back to original path if canonicalization fails.
137    pub(crate) fn get_canonical_path(&self, path: PathBuf) -> PathBuf {
138        // Check cache first
139        if let Some(cached) = self.canonical_path_cache.get(&path) {
140            return cached.value().clone();
141        }
142
143        // Attempt canonicalization
144        let canonical = path.canonicalize().unwrap_or_else(|_| {
145            debug!("Could not canonicalize path {:?}, using as-is", path);
146            path.clone()
147        });
148
149        // Store in cache for future lookups
150        self.canonical_path_cache.insert(path, canonical.clone());
151        canonical
152    }
153
154    /// Get file content from cache or read from filesystem.
155    /// Returns None if file cannot be read.
156    pub(crate) fn get_file_content(&self, file_path: &Path) -> Option<Arc<String>> {
157        if let Some(cached) = self.file_cache.get(file_path) {
158            Some(Arc::clone(cached.value()))
159        } else {
160            std::fs::read_to_string(file_path).ok().map(Arc::new)
161        }
162    }
163
164    /// Get or compute line index for a file, with content-hash-based caching.
165    /// Returns Arc to avoid cloning the potentially large Vec.
166    /// The cache is invalidated when the content hash changes.
167    pub(crate) fn get_line_index(&self, file_path: &Path, content: &str) -> Arc<Vec<usize>> {
168        let content_hash = Self::hash_content(content);
169
170        // Check cache first - only use if content hash matches
171        if let Some(cached) = self.line_index_cache.get(file_path) {
172            let (cached_hash, cached_index) = cached.value();
173            if *cached_hash == content_hash {
174                return Arc::clone(cached_index);
175            }
176        }
177
178        // Build line index
179        let line_index = Self::build_line_index(content);
180        let arc_index = Arc::new(line_index);
181
182        // Store in cache with content hash
183        self.line_index_cache.insert(
184            file_path.to_path_buf(),
185            (content_hash, Arc::clone(&arc_index)),
186        );
187
188        arc_index
189    }
190
191    /// Get or parse AST for a file, with content-hash-based caching.
192    /// Returns Arc to avoid cloning the potentially large AST.
193    /// The cache is invalidated when the content hash changes.
194    pub(crate) fn get_parsed_ast(
195        &self,
196        file_path: &Path,
197        content: &str,
198    ) -> Option<Arc<rustpython_parser::ast::Mod>> {
199        let content_hash = Self::hash_content(content);
200
201        // Check cache first - only use if content hash matches
202        if let Some(cached) = self.ast_cache.get(file_path) {
203            let (cached_hash, cached_ast) = cached.value();
204            if *cached_hash == content_hash {
205                return Some(Arc::clone(cached_ast));
206            }
207        }
208
209        // Parse the content
210        let parsed = rustpython_parser::parse(content, rustpython_parser::Mode::Module, "").ok()?;
211        let arc_ast = Arc::new(parsed);
212
213        // Store in cache with content hash
214        self.ast_cache.insert(
215            file_path.to_path_buf(),
216            (content_hash, Arc::clone(&arc_ast)),
217        );
218
219        Some(arc_ast)
220    }
221
222    /// Compute a hash of the content for cache invalidation.
223    fn hash_content(content: &str) -> u64 {
224        let mut hasher = DefaultHasher::new();
225        content.hash(&mut hasher);
226        hasher.finish()
227    }
228
229    /// Remove all cached data for a file.
230    /// Called when a file is closed or deleted to prevent unbounded memory growth.
231    pub fn cleanup_file_cache(&self, file_path: &Path) {
232        // Use canonical path for consistent cleanup
233        let canonical = file_path
234            .canonicalize()
235            .unwrap_or_else(|_| file_path.to_path_buf());
236
237        debug!("Cleaning up cache for file: {:?}", canonical);
238
239        // Remove from line_index_cache
240        self.line_index_cache.remove(&canonical);
241
242        // Remove from ast_cache
243        self.ast_cache.remove(&canonical);
244
245        // Remove from file_cache
246        self.file_cache.remove(&canonical);
247
248        // Remove from available_fixtures_cache (this file's cached available fixtures)
249        self.available_fixtures_cache.remove(&canonical);
250
251        // Remove from imported_fixtures_cache
252        self.imported_fixtures_cache.remove(&canonical);
253
254        // Note: We don't remove from canonical_path_cache because:
255        // 1. It's keyed by original path, not canonical path
256        // 2. Path->canonical mappings are stable and small
257        // 3. They may be needed again if file is reopened
258
259        // Note: We don't remove definitions/usages here because:
260        // 1. They might be needed for cross-file references
261        // 2. They're cleaned up on next analyze_file call anyway
262    }
263
264    /// Evict entries from caches if they exceed the maximum size.
265    /// Called periodically to prevent unbounded memory growth in very large workspaces.
266    /// Most LSPs rely on did_close cleanup for open files; this is a safety net for
267    /// workspace scan files that accumulate over time.
268    pub(crate) fn evict_cache_if_needed(&self) {
269        // Only evict if significantly over limit to avoid frequent eviction
270        if self.file_cache.len() > MAX_FILE_CACHE_SIZE {
271            debug!(
272                "File cache size ({}) exceeds limit ({}), evicting entries",
273                self.file_cache.len(),
274                MAX_FILE_CACHE_SIZE
275            );
276
277            // Remove ~25% of entries to avoid frequent re-eviction
278            let to_remove_count = self.file_cache.len() / 4;
279            let to_remove: Vec<PathBuf> = self
280                .file_cache
281                .iter()
282                .take(to_remove_count)
283                .map(|entry| entry.key().clone())
284                .collect();
285
286            for path in to_remove {
287                self.file_cache.remove(&path);
288                // Also clean related caches for consistency
289                self.line_index_cache.remove(&path);
290                self.ast_cache.remove(&path);
291                self.available_fixtures_cache.remove(&path);
292                self.imported_fixtures_cache.remove(&path);
293            }
294
295            debug!(
296                "Cache eviction complete, new size: {}",
297                self.file_cache.len()
298            );
299        }
300    }
301}