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