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