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}