pytest_language_server/
fixtures.rs

1use dashmap::DashMap;
2use rustpython_parser::ast::{Expr, Stmt};
3use rustpython_parser::{parse, Mode};
4use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tracing::{debug, error, info, warn};
8use walkdir::WalkDir;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct FixtureDefinition {
12    pub name: String,
13    pub file_path: PathBuf,
14    pub line: usize,
15    pub docstring: Option<String>,
16    pub return_type: Option<String>, // The return type annotation (for generators, the yielded type)
17}
18
19#[derive(Debug, Clone)]
20pub struct FixtureUsage {
21    pub name: String,
22    pub file_path: PathBuf,
23    pub line: usize,
24    pub start_char: usize, // Character position where this usage starts (on the line)
25    pub end_char: usize,   // Character position where this usage ends (on the line)
26}
27
28#[derive(Debug, Clone)]
29pub struct UndeclaredFixture {
30    pub name: String,
31    pub file_path: PathBuf,
32    pub line: usize,
33    pub start_char: usize,
34    pub end_char: usize,
35    pub function_name: String, // Name of the test/fixture function where this is used
36    pub function_line: usize,  // Line where the function is defined
37}
38
39#[derive(Debug)]
40pub struct FixtureDatabase {
41    // Map from fixture name to all its definitions (can be in multiple conftest.py files)
42    pub definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
43    // Map from file path to fixtures used in that file
44    pub usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
45    // Cache of file contents for analyzed files (uses Arc for efficient sharing)
46    pub file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
47    // Map from file path to undeclared fixtures used in function bodies
48    pub undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
49    // Map from file path to imported names in that file
50    pub imports: Arc<DashMap<PathBuf, std::collections::HashSet<String>>>,
51    // Cache of canonical paths to avoid repeated filesystem calls
52    pub canonical_path_cache: Arc<DashMap<PathBuf, PathBuf>>,
53}
54
55impl Default for FixtureDatabase {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl FixtureDatabase {
62    pub fn new() -> Self {
63        Self {
64            definitions: Arc::new(DashMap::new()),
65            usages: Arc::new(DashMap::new()),
66            file_cache: Arc::new(DashMap::new()),
67            undeclared_fixtures: Arc::new(DashMap::new()),
68            imports: Arc::new(DashMap::new()),
69            canonical_path_cache: Arc::new(DashMap::new()),
70        }
71    }
72
73    /// Get canonical path with caching to avoid repeated filesystem calls
74    /// Falls back to original path if canonicalization fails
75    fn get_canonical_path(&self, path: PathBuf) -> PathBuf {
76        // Check cache first
77        if let Some(cached) = self.canonical_path_cache.get(&path) {
78            return cached.value().clone();
79        }
80
81        // Attempt canonicalization
82        let canonical = path.canonicalize().unwrap_or_else(|_| {
83            debug!("Could not canonicalize path {:?}, using as-is", path);
84            path.clone()
85        });
86
87        // Store in cache for future lookups
88        self.canonical_path_cache.insert(path, canonical.clone());
89        canonical
90    }
91
92    /// Get file content from cache or read from filesystem
93    /// Returns None if file cannot be read
94    fn get_file_content(&self, file_path: &Path) -> Option<Arc<String>> {
95        if let Some(cached) = self.file_cache.get(file_path) {
96            Some(Arc::clone(cached.value()))
97        } else {
98            std::fs::read_to_string(file_path).ok().map(Arc::new)
99        }
100    }
101
102    /// Scan a workspace directory for test files and conftest.py files
103    pub fn scan_workspace(&self, root_path: &Path) {
104        info!("Scanning workspace: {:?}", root_path);
105        let mut file_count = 0;
106        let mut error_count = 0;
107
108        for entry in WalkDir::new(root_path).into_iter() {
109            let entry = match entry {
110                Ok(e) => e,
111                Err(err) => {
112                    // Log directory traversal errors (permission denied, etc.)
113                    if err
114                        .io_error()
115                        .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
116                    {
117                        warn!(
118                            "Permission denied accessing path during workspace scan: {}",
119                            err
120                        );
121                    } else {
122                        error!("Error during workspace scan: {}", err);
123                        error_count += 1;
124                    }
125                    continue;
126                }
127            };
128
129            let path = entry.path();
130
131            // Look for conftest.py or test_*.py or *_test.py files
132            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
133                if filename == "conftest.py"
134                    || filename.starts_with("test_") && filename.ends_with(".py")
135                    || filename.ends_with("_test.py")
136                {
137                    debug!("Found test/conftest file: {:?}", path);
138                    match std::fs::read_to_string(path) {
139                        Ok(content) => {
140                            self.analyze_file(path.to_path_buf(), &content);
141                            file_count += 1;
142                        }
143                        Err(err) => {
144                            if err.kind() == std::io::ErrorKind::PermissionDenied {
145                                warn!("Permission denied reading file: {:?}", path);
146                            } else {
147                                error!("Failed to read file {:?}: {}", path, err);
148                                error_count += 1;
149                            }
150                        }
151                    }
152                }
153            }
154        }
155
156        if error_count > 0 {
157            error!("Workspace scan completed with {} errors", error_count);
158        }
159
160        info!("Workspace scan complete. Processed {} files", file_count);
161
162        // Also scan virtual environment for pytest plugins
163        self.scan_venv_fixtures(root_path);
164
165        info!("Total fixtures defined: {}", self.definitions.len());
166        info!("Total files with fixture usages: {}", self.usages.len());
167    }
168
169    /// Scan virtual environment for pytest plugin fixtures
170    fn scan_venv_fixtures(&self, root_path: &Path) {
171        info!("Scanning for pytest plugins in virtual environment");
172
173        // Try to find virtual environment
174        let venv_paths = vec![
175            root_path.join(".venv"),
176            root_path.join("venv"),
177            root_path.join("env"),
178        ];
179
180        info!("Checking for venv in: {:?}", root_path);
181        for venv_path in &venv_paths {
182            debug!("Checking venv path: {:?}", venv_path);
183            if venv_path.exists() {
184                info!("Found virtual environment at: {:?}", venv_path);
185                self.scan_venv_site_packages(venv_path);
186                return;
187            } else {
188                debug!("  Does not exist: {:?}", venv_path);
189            }
190        }
191
192        // Also check for system-wide VIRTUAL_ENV
193        if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
194            info!("Found VIRTUAL_ENV environment variable: {}", venv);
195            let venv_path = PathBuf::from(venv);
196            if venv_path.exists() {
197                info!("Using VIRTUAL_ENV: {:?}", venv_path);
198                self.scan_venv_site_packages(&venv_path);
199                return;
200            } else {
201                warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
202            }
203        } else {
204            debug!("No VIRTUAL_ENV environment variable set");
205        }
206
207        warn!("No virtual environment found - third-party fixtures will not be available");
208    }
209
210    fn scan_venv_site_packages(&self, venv_path: &Path) {
211        info!("Scanning venv site-packages in: {:?}", venv_path);
212
213        // Find site-packages directory
214        let lib_path = venv_path.join("lib");
215        debug!("Checking lib path: {:?}", lib_path);
216
217        if lib_path.exists() {
218            // Look for python* directories
219            if let Ok(entries) = std::fs::read_dir(&lib_path) {
220                for entry in entries.flatten() {
221                    let path = entry.path();
222                    let dirname = path.file_name().unwrap_or_default().to_string_lossy();
223                    debug!("Found in lib: {:?}", dirname);
224
225                    if path.is_dir() && dirname.starts_with("python") {
226                        let site_packages = path.join("site-packages");
227                        debug!("Checking site-packages: {:?}", site_packages);
228
229                        if site_packages.exists() {
230                            info!("Found site-packages: {:?}", site_packages);
231                            self.scan_pytest_plugins(&site_packages);
232                            return;
233                        }
234                    }
235                }
236            }
237        }
238
239        // Try Windows path
240        let windows_site_packages = venv_path.join("Lib/site-packages");
241        debug!("Checking Windows path: {:?}", windows_site_packages);
242        if windows_site_packages.exists() {
243            info!("Found site-packages (Windows): {:?}", windows_site_packages);
244            self.scan_pytest_plugins(&windows_site_packages);
245            return;
246        }
247
248        warn!("Could not find site-packages in venv: {:?}", venv_path);
249    }
250
251    fn scan_pytest_plugins(&self, site_packages: &Path) {
252        info!("Scanning pytest plugins in: {:?}", site_packages);
253
254        // List of known pytest plugin prefixes/packages
255        let pytest_packages = vec![
256            // Existing plugins
257            "pytest_mock",
258            "pytest-mock",
259            "pytest_asyncio",
260            "pytest-asyncio",
261            "pytest_django",
262            "pytest-django",
263            "pytest_cov",
264            "pytest-cov",
265            "pytest_xdist",
266            "pytest-xdist",
267            "pytest_fixtures",
268            // Additional popular plugins
269            "pytest_flask",
270            "pytest-flask",
271            "pytest_httpx",
272            "pytest-httpx",
273            "pytest_postgresql",
274            "pytest-postgresql",
275            "pytest_mongodb",
276            "pytest-mongodb",
277            "pytest_redis",
278            "pytest-redis",
279            "pytest_elasticsearch",
280            "pytest-elasticsearch",
281            "pytest_rabbitmq",
282            "pytest-rabbitmq",
283            "pytest_mysql",
284            "pytest-mysql",
285            "pytest_docker",
286            "pytest-docker",
287            "pytest_kubernetes",
288            "pytest-kubernetes",
289            "pytest_celery",
290            "pytest-celery",
291            "pytest_tornado",
292            "pytest-tornado",
293            "pytest_aiohttp",
294            "pytest-aiohttp",
295            "pytest_sanic",
296            "pytest-sanic",
297            "pytest_fastapi",
298            "pytest-fastapi",
299            "pytest_alembic",
300            "pytest-alembic",
301            "pytest_sqlalchemy",
302            "pytest-sqlalchemy",
303            "pytest_factoryboy",
304            "pytest-factoryboy",
305            "pytest_freezegun",
306            "pytest-freezegun",
307            "pytest_mimesis",
308            "pytest-mimesis",
309            "pytest_lazy_fixture",
310            "pytest-lazy-fixture",
311            "pytest_cases",
312            "pytest-cases",
313            "pytest_bdd",
314            "pytest-bdd",
315            "pytest_benchmark",
316            "pytest-benchmark",
317            "pytest_timeout",
318            "pytest-timeout",
319            "pytest_retry",
320            "pytest-retry",
321            "pytest_repeat",
322            "pytest-repeat",
323            "pytest_rerunfailures",
324            "pytest-rerunfailures",
325            "pytest_ordering",
326            "pytest-ordering",
327            "pytest_dependency",
328            "pytest-dependency",
329            "pytest_random_order",
330            "pytest-random-order",
331            "pytest_picked",
332            "pytest-picked",
333            "pytest_testmon",
334            "pytest-testmon",
335            "pytest_split",
336            "pytest-split",
337            "pytest_env",
338            "pytest-env",
339            "pytest_dotenv",
340            "pytest-dotenv",
341            "pytest_html",
342            "pytest-html",
343            "pytest_json_report",
344            "pytest-json-report",
345            "pytest_metadata",
346            "pytest-metadata",
347            "pytest_instafail",
348            "pytest-instafail",
349            "pytest_clarity",
350            "pytest-clarity",
351            "pytest_sugar",
352            "pytest-sugar",
353            "pytest_emoji",
354            "pytest-emoji",
355            "pytest_play",
356            "pytest-play",
357            "pytest_selenium",
358            "pytest-selenium",
359            "pytest_playwright",
360            "pytest-playwright",
361            "pytest_splinter",
362            "pytest-splinter",
363        ];
364
365        let mut plugin_count = 0;
366
367        for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
368            let entry = match entry {
369                Ok(e) => e,
370                Err(_) => continue,
371            };
372
373            let path = entry.path();
374            let filename = path.file_name().unwrap_or_default().to_string_lossy();
375
376            // Check if this is a pytest-related package
377            let is_pytest_package = pytest_packages.iter().any(|pkg| filename.contains(pkg))
378                || filename.starts_with("pytest")
379                || filename.contains("_pytest");
380
381            if is_pytest_package && path.is_dir() {
382                // Skip .dist-info directories - they don't contain code
383                if filename.ends_with(".dist-info") || filename.ends_with(".egg-info") {
384                    debug!("Skipping dist-info directory: {:?}", filename);
385                    continue;
386                }
387
388                info!("Scanning pytest plugin: {:?}", path);
389                plugin_count += 1;
390                self.scan_plugin_directory(&path);
391            } else {
392                // Log packages we're skipping for debugging
393                if filename.contains("mock") {
394                    debug!("Found mock-related package (not scanning): {:?}", filename);
395                }
396            }
397        }
398
399        info!("Scanned {} pytest plugin packages", plugin_count);
400    }
401
402    fn scan_plugin_directory(&self, plugin_dir: &Path) {
403        // Recursively scan for Python files with fixtures
404        for entry in WalkDir::new(plugin_dir)
405            .max_depth(3) // Limit depth to avoid scanning too much
406            .into_iter()
407            .filter_map(|e| e.ok())
408        {
409            let path = entry.path();
410
411            if path.extension().and_then(|s| s.to_str()) == Some("py") {
412                // Only scan files that might have fixtures (not test files)
413                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
414                    // Skip test files and __pycache__
415                    if filename.starts_with("test_") || filename.contains("__pycache__") {
416                        continue;
417                    }
418
419                    debug!("Scanning plugin file: {:?}", path);
420                    if let Ok(content) = std::fs::read_to_string(path) {
421                        self.analyze_file(path.to_path_buf(), &content);
422                    }
423                }
424            }
425        }
426    }
427
428    /// Analyze a single Python file for fixtures using AST parsing
429    pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
430        // Use cached canonical path to avoid repeated filesystem calls
431        let file_path = self.get_canonical_path(file_path);
432
433        debug!("Analyzing file: {:?}", file_path);
434
435        // Cache the file content for later use (e.g., in find_fixture_definition)
436        // Use Arc for efficient sharing without cloning
437        self.file_cache
438            .insert(file_path.clone(), Arc::new(content.to_string()));
439
440        // Parse the Python code
441        let parsed = match parse(content, Mode::Module, "") {
442            Ok(ast) => ast,
443            Err(e) => {
444                error!("Failed to parse Python file {:?}: {}", file_path, e);
445                return;
446            }
447        };
448
449        // Clear previous usages for this file
450        self.usages.remove(&file_path);
451
452        // Clear previous undeclared fixtures for this file
453        self.undeclared_fixtures.remove(&file_path);
454
455        // Clear previous imports for this file
456        self.imports.remove(&file_path);
457
458        // Clear previous fixture definitions from this file
459        // We need to remove definitions that were in this file
460        // IMPORTANT: Collect keys first to avoid deadlock. The issue is that
461        // iter() holds read locks on the DashMap, and if we try to call .get() or
462        // .insert() on the same map while iterating, we'll deadlock due to lock
463        // contention. Collecting keys first releases the iterator locks before
464        // we start mutating the map.
465        let keys: Vec<String> = {
466            let mut k = Vec::new();
467            for entry in self.definitions.iter() {
468                k.push(entry.key().clone());
469            }
470            k
471        }; // Iterator dropped here, all locks released
472
473        // Now process each key individually
474        for key in keys {
475            // Get current definitions for this key
476            let current_defs = match self.definitions.get(&key) {
477                Some(defs) => defs.clone(),
478                None => continue,
479            };
480
481            // Filter out definitions from this file
482            let filtered: Vec<FixtureDefinition> = current_defs
483                .iter()
484                .filter(|def| def.file_path != file_path)
485                .cloned()
486                .collect();
487
488            // Update or remove
489            if filtered.is_empty() {
490                self.definitions.remove(&key);
491            } else if filtered.len() != current_defs.len() {
492                // Only update if something changed
493                self.definitions.insert(key, filtered);
494            }
495        }
496
497        // Check if this is a conftest.py
498        let is_conftest = file_path
499            .file_name()
500            .map(|n| n == "conftest.py")
501            .unwrap_or(false);
502        debug!("is_conftest: {}", is_conftest);
503
504        // Build line index for O(1) line lookups
505        let line_index = Self::build_line_index(content);
506
507        // Process each statement in the module
508        if let rustpython_parser::ast::Mod::Module(module) = parsed {
509            debug!("Module has {} statements", module.body.len());
510
511            // First pass: collect all module-level names (imports, assignments, function/class defs)
512            let mut module_level_names = std::collections::HashSet::new();
513            for stmt in &module.body {
514                self.collect_module_level_names(stmt, &mut module_level_names);
515            }
516            self.imports.insert(file_path.clone(), module_level_names);
517
518            // Second pass: analyze fixtures and tests
519            for stmt in &module.body {
520                self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
521            }
522        }
523
524        debug!("Analysis complete for {:?}", file_path);
525    }
526
527    fn visit_stmt(
528        &self,
529        stmt: &Stmt,
530        file_path: &PathBuf,
531        _is_conftest: bool,
532        content: &str,
533        line_index: &[usize],
534    ) {
535        // First check for assignment-style fixtures: fixture_name = pytest.fixture()(func)
536        if let Stmt::Assign(assign) = stmt {
537            self.visit_assignment_fixture(assign, file_path, content, line_index);
538        }
539
540        // Handle both regular and async function definitions
541        let (func_name, decorator_list, args, range, body, returns) = match stmt {
542            Stmt::FunctionDef(func_def) => (
543                func_def.name.as_str(),
544                &func_def.decorator_list,
545                &func_def.args,
546                func_def.range,
547                &func_def.body,
548                &func_def.returns,
549            ),
550            Stmt::AsyncFunctionDef(func_def) => (
551                func_def.name.as_str(),
552                &func_def.decorator_list,
553                &func_def.args,
554                func_def.range,
555                &func_def.body,
556                &func_def.returns,
557            ),
558            _ => return,
559        };
560
561        debug!("Found function: {}", func_name);
562
563        // Check if this is a fixture definition
564        debug!(
565            "Function {} has {} decorators",
566            func_name,
567            decorator_list.len()
568        );
569        let is_fixture = decorator_list.iter().any(|dec| {
570            let result = Self::is_fixture_decorator(dec);
571            if result {
572                debug!("  Decorator matched as fixture!");
573            }
574            result
575        });
576
577        if is_fixture {
578            // Calculate line number from the range start
579            let line = self.get_line_from_offset(range.start().to_usize(), line_index);
580
581            // Extract docstring if present
582            let docstring = self.extract_docstring(body);
583
584            // Extract return type annotation
585            let return_type = self.extract_return_type(returns, body, content);
586
587            info!(
588                "Found fixture definition: {} at {:?}:{}",
589                func_name, file_path, line
590            );
591            if let Some(ref doc) = docstring {
592                debug!("  Docstring: {}", doc);
593            }
594            if let Some(ref ret_type) = return_type {
595                debug!("  Return type: {}", ret_type);
596            }
597
598            let definition = FixtureDefinition {
599                name: func_name.to_string(),
600                file_path: file_path.clone(),
601                line,
602                docstring,
603                return_type,
604            };
605
606            self.definitions
607                .entry(func_name.to_string())
608                .or_default()
609                .push(definition);
610
611            // Fixtures can depend on other fixtures - record these as usages too
612            let mut declared_params: std::collections::HashSet<String> =
613                std::collections::HashSet::new();
614            declared_params.insert("self".to_string());
615            declared_params.insert("request".to_string());
616            declared_params.insert(func_name.to_string()); // Exclude function name itself
617
618            for arg in &args.args {
619                let arg_name = arg.def.arg.as_str();
620                declared_params.insert(arg_name.to_string());
621
622                if arg_name != "self" && arg_name != "request" {
623                    // Get the actual line where this parameter appears
624                    // arg.def.range contains the location of the parameter name
625                    let arg_line =
626                        self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
627                    let start_char = self.get_char_position_from_offset(
628                        arg.def.range.start().to_usize(),
629                        line_index,
630                    );
631                    let end_char = self
632                        .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
633
634                    info!(
635                        "Found fixture dependency: {} at {:?}:{}:{}",
636                        arg_name, file_path, arg_line, start_char
637                    );
638
639                    let usage = FixtureUsage {
640                        name: arg_name.to_string(),
641                        file_path: file_path.clone(),
642                        line: arg_line, // Use actual parameter line
643                        start_char,
644                        end_char,
645                    };
646
647                    self.usages
648                        .entry(file_path.clone())
649                        .or_default()
650                        .push(usage);
651                }
652            }
653
654            // Scan fixture body for undeclared fixture usages
655            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
656            self.scan_function_body_for_undeclared_fixtures(
657                body,
658                file_path,
659                content,
660                line_index,
661                &declared_params,
662                func_name,
663                function_line,
664            );
665        }
666
667        // Check if this is a test function
668        let is_test = func_name.starts_with("test_");
669
670        if is_test {
671            debug!("Found test function: {}", func_name);
672
673            // Collect declared parameters
674            let mut declared_params: std::collections::HashSet<String> =
675                std::collections::HashSet::new();
676            declared_params.insert("self".to_string());
677            declared_params.insert("request".to_string()); // pytest built-in
678
679            // Extract fixture usages from function parameters
680            for arg in &args.args {
681                let arg_name = arg.def.arg.as_str();
682                declared_params.insert(arg_name.to_string());
683
684                if arg_name != "self" {
685                    // Get the actual line where this parameter appears
686                    // This handles multiline function signatures correctly
687                    // arg.def.range contains the location of the parameter name
688                    let arg_offset = arg.def.range.start().to_usize();
689                    let arg_line = self.get_line_from_offset(arg_offset, line_index);
690                    let start_char = self.get_char_position_from_offset(arg_offset, line_index);
691                    let end_char = self
692                        .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
693
694                    debug!(
695                        "Parameter {} at offset {}, calculated line {}, char {}",
696                        arg_name, arg_offset, arg_line, start_char
697                    );
698                    info!(
699                        "Found fixture usage: {} at {:?}:{}:{}",
700                        arg_name, file_path, arg_line, start_char
701                    );
702
703                    let usage = FixtureUsage {
704                        name: arg_name.to_string(),
705                        file_path: file_path.clone(),
706                        line: arg_line, // Use actual parameter line
707                        start_char,
708                        end_char,
709                    };
710
711                    // Append to existing usages for this file
712                    self.usages
713                        .entry(file_path.clone())
714                        .or_default()
715                        .push(usage);
716                }
717            }
718
719            // Now scan the function body for undeclared fixture usages
720            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
721            self.scan_function_body_for_undeclared_fixtures(
722                body,
723                file_path,
724                content,
725                line_index,
726                &declared_params,
727                func_name,
728                function_line,
729            );
730        }
731    }
732
733    fn visit_assignment_fixture(
734        &self,
735        assign: &rustpython_parser::ast::StmtAssign,
736        file_path: &PathBuf,
737        _content: &str,
738        line_index: &[usize],
739    ) {
740        // Check for pattern: fixture_name = pytest.fixture()(func)
741        // The value should be a Call expression where the func is a Call to pytest.fixture()
742
743        if let Expr::Call(outer_call) = &*assign.value {
744            // Check if outer_call.func is pytest.fixture() or fixture()
745            if let Expr::Call(inner_call) = &*outer_call.func {
746                if Self::is_fixture_decorator(&inner_call.func) {
747                    // This is pytest.fixture()(something)
748                    // Get the fixture name from the assignment target
749                    for target in &assign.targets {
750                        if let Expr::Name(name) = target {
751                            let fixture_name = name.id.as_str();
752                            let line = self
753                                .get_line_from_offset(assign.range.start().to_usize(), line_index);
754
755                            info!(
756                                "Found fixture assignment: {} at {:?}:{}",
757                                fixture_name, file_path, line
758                            );
759
760                            // We don't have a docstring or return type for assignment-style fixtures
761                            let definition = FixtureDefinition {
762                                name: fixture_name.to_string(),
763                                file_path: file_path.clone(),
764                                line,
765                                docstring: None,
766                                return_type: None,
767                            };
768
769                            self.definitions
770                                .entry(fixture_name.to_string())
771                                .or_default()
772                                .push(definition);
773                        }
774                    }
775                }
776            }
777        }
778    }
779
780    fn is_fixture_decorator(expr: &Expr) -> bool {
781        match expr {
782            Expr::Name(name) => name.id.as_str() == "fixture",
783            Expr::Attribute(attr) => {
784                // Check for pytest.fixture
785                if let Expr::Name(value) = &*attr.value {
786                    value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
787                } else {
788                    false
789                }
790            }
791            Expr::Call(call) => {
792                // Handle @pytest.fixture() or @fixture() with parentheses
793                Self::is_fixture_decorator(&call.func)
794            }
795            _ => false,
796        }
797    }
798
799    #[allow(clippy::too_many_arguments)]
800    fn scan_function_body_for_undeclared_fixtures(
801        &self,
802        body: &[Stmt],
803        file_path: &PathBuf,
804        content: &str,
805        line_index: &[usize],
806        declared_params: &std::collections::HashSet<String>,
807        function_name: &str,
808        function_line: usize,
809    ) {
810        // First, collect all local variable names with their definition line numbers
811        let mut local_vars = std::collections::HashMap::new();
812        self.collect_local_variables(body, content, line_index, &mut local_vars);
813
814        // Also add imported names to local_vars (they shouldn't be flagged as undeclared fixtures)
815        // We set their line to 0 so they're treated as always in scope (line 0 < any actual usage line)
816        if let Some(imports) = self.imports.get(file_path) {
817            for import in imports.iter() {
818                local_vars.insert(import.clone(), 0);
819            }
820        }
821
822        // Walk through the function body and find all Name references
823        for stmt in body {
824            self.visit_stmt_for_names(
825                stmt,
826                file_path,
827                content,
828                line_index,
829                declared_params,
830                &local_vars,
831                function_name,
832                function_line,
833            );
834        }
835    }
836
837    fn collect_module_level_names(
838        &self,
839        stmt: &Stmt,
840        names: &mut std::collections::HashSet<String>,
841    ) {
842        match stmt {
843            // Imports
844            Stmt::Import(import_stmt) => {
845                for alias in &import_stmt.names {
846                    // If there's an "as" alias, use that; otherwise use the original name
847                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
848                    names.insert(name.to_string());
849                }
850            }
851            Stmt::ImportFrom(import_from) => {
852                for alias in &import_from.names {
853                    // If there's an "as" alias, use that; otherwise use the original name
854                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
855                    names.insert(name.to_string());
856                }
857            }
858            // Regular function definitions (not fixtures)
859            Stmt::FunctionDef(func_def) => {
860                // Check if this is NOT a fixture
861                let is_fixture = func_def
862                    .decorator_list
863                    .iter()
864                    .any(Self::is_fixture_decorator);
865                if !is_fixture {
866                    names.insert(func_def.name.to_string());
867                }
868            }
869            // Async function definitions (not fixtures)
870            Stmt::AsyncFunctionDef(func_def) => {
871                let is_fixture = func_def
872                    .decorator_list
873                    .iter()
874                    .any(Self::is_fixture_decorator);
875                if !is_fixture {
876                    names.insert(func_def.name.to_string());
877                }
878            }
879            // Class definitions
880            Stmt::ClassDef(class_def) => {
881                names.insert(class_def.name.to_string());
882            }
883            // Module-level assignments
884            Stmt::Assign(assign) => {
885                for target in &assign.targets {
886                    self.collect_names_from_expr(target, names);
887                }
888            }
889            Stmt::AnnAssign(ann_assign) => {
890                self.collect_names_from_expr(&ann_assign.target, names);
891            }
892            _ => {}
893        }
894    }
895
896    #[allow(clippy::only_used_in_recursion)]
897    fn collect_local_variables(
898        &self,
899        body: &[Stmt],
900        content: &str,
901        line_index: &[usize],
902        local_vars: &mut std::collections::HashMap<String, usize>,
903    ) {
904        for stmt in body {
905            match stmt {
906                Stmt::Assign(assign) => {
907                    // Collect variable names from left-hand side with their line numbers
908                    let line =
909                        self.get_line_from_offset(assign.range.start().to_usize(), line_index);
910                    let mut temp_names = std::collections::HashSet::new();
911                    for target in &assign.targets {
912                        self.collect_names_from_expr(target, &mut temp_names);
913                    }
914                    for name in temp_names {
915                        local_vars.insert(name, line);
916                    }
917                }
918                Stmt::AnnAssign(ann_assign) => {
919                    // Collect annotated assignment targets with their line numbers
920                    let line =
921                        self.get_line_from_offset(ann_assign.range.start().to_usize(), line_index);
922                    let mut temp_names = std::collections::HashSet::new();
923                    self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
924                    for name in temp_names {
925                        local_vars.insert(name, line);
926                    }
927                }
928                Stmt::AugAssign(aug_assign) => {
929                    // Collect augmented assignment targets (+=, -=, etc.)
930                    let line =
931                        self.get_line_from_offset(aug_assign.range.start().to_usize(), line_index);
932                    let mut temp_names = std::collections::HashSet::new();
933                    self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
934                    for name in temp_names {
935                        local_vars.insert(name, line);
936                    }
937                }
938                Stmt::For(for_stmt) => {
939                    // Collect loop variable with its line number
940                    let line =
941                        self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
942                    let mut temp_names = std::collections::HashSet::new();
943                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
944                    for name in temp_names {
945                        local_vars.insert(name, line);
946                    }
947                    // Recursively collect from body
948                    self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
949                }
950                Stmt::AsyncFor(for_stmt) => {
951                    let line =
952                        self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
953                    let mut temp_names = std::collections::HashSet::new();
954                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
955                    for name in temp_names {
956                        local_vars.insert(name, line);
957                    }
958                    self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
959                }
960                Stmt::While(while_stmt) => {
961                    self.collect_local_variables(&while_stmt.body, content, line_index, local_vars);
962                }
963                Stmt::If(if_stmt) => {
964                    self.collect_local_variables(&if_stmt.body, content, line_index, local_vars);
965                    self.collect_local_variables(&if_stmt.orelse, content, line_index, local_vars);
966                }
967                Stmt::With(with_stmt) => {
968                    // Collect context manager variables with their line numbers
969                    let line =
970                        self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
971                    for item in &with_stmt.items {
972                        if let Some(ref optional_vars) = item.optional_vars {
973                            let mut temp_names = std::collections::HashSet::new();
974                            self.collect_names_from_expr(optional_vars, &mut temp_names);
975                            for name in temp_names {
976                                local_vars.insert(name, line);
977                            }
978                        }
979                    }
980                    self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
981                }
982                Stmt::AsyncWith(with_stmt) => {
983                    let line =
984                        self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
985                    for item in &with_stmt.items {
986                        if let Some(ref optional_vars) = item.optional_vars {
987                            let mut temp_names = std::collections::HashSet::new();
988                            self.collect_names_from_expr(optional_vars, &mut temp_names);
989                            for name in temp_names {
990                                local_vars.insert(name, line);
991                            }
992                        }
993                    }
994                    self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
995                }
996                Stmt::Try(try_stmt) => {
997                    self.collect_local_variables(&try_stmt.body, content, line_index, local_vars);
998                    // TODO: ExceptHandler struct doesn't expose exception variable name or
999                    // body in rustpython-parser 0.4.0. This means we can't collect local
1000                    // variables from except blocks. Should be revisited if parser is upgraded.
1001                    self.collect_local_variables(&try_stmt.orelse, content, line_index, local_vars);
1002                    self.collect_local_variables(
1003                        &try_stmt.finalbody,
1004                        content,
1005                        line_index,
1006                        local_vars,
1007                    );
1008                }
1009                _ => {}
1010            }
1011        }
1012    }
1013
1014    #[allow(clippy::only_used_in_recursion)]
1015    fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
1016        match expr {
1017            Expr::Name(name) => {
1018                names.insert(name.id.to_string());
1019            }
1020            Expr::Tuple(tuple) => {
1021                for elt in &tuple.elts {
1022                    self.collect_names_from_expr(elt, names);
1023                }
1024            }
1025            Expr::List(list) => {
1026                for elt in &list.elts {
1027                    self.collect_names_from_expr(elt, names);
1028                }
1029            }
1030            _ => {}
1031        }
1032    }
1033
1034    #[allow(clippy::too_many_arguments)]
1035    fn visit_stmt_for_names(
1036        &self,
1037        stmt: &Stmt,
1038        file_path: &PathBuf,
1039        content: &str,
1040        line_index: &[usize],
1041        declared_params: &std::collections::HashSet<String>,
1042        local_vars: &std::collections::HashMap<String, usize>,
1043        function_name: &str,
1044        function_line: usize,
1045    ) {
1046        match stmt {
1047            Stmt::Expr(expr_stmt) => {
1048                self.visit_expr_for_names(
1049                    &expr_stmt.value,
1050                    file_path,
1051                    content,
1052                    line_index,
1053                    declared_params,
1054                    local_vars,
1055                    function_name,
1056                    function_line,
1057                );
1058            }
1059            Stmt::Assign(assign) => {
1060                self.visit_expr_for_names(
1061                    &assign.value,
1062                    file_path,
1063                    content,
1064                    line_index,
1065                    declared_params,
1066                    local_vars,
1067                    function_name,
1068                    function_line,
1069                );
1070            }
1071            Stmt::AugAssign(aug_assign) => {
1072                self.visit_expr_for_names(
1073                    &aug_assign.value,
1074                    file_path,
1075                    content,
1076                    line_index,
1077                    declared_params,
1078                    local_vars,
1079                    function_name,
1080                    function_line,
1081                );
1082            }
1083            Stmt::Return(ret) => {
1084                if let Some(ref value) = ret.value {
1085                    self.visit_expr_for_names(
1086                        value,
1087                        file_path,
1088                        content,
1089                        line_index,
1090                        declared_params,
1091                        local_vars,
1092                        function_name,
1093                        function_line,
1094                    );
1095                }
1096            }
1097            Stmt::If(if_stmt) => {
1098                self.visit_expr_for_names(
1099                    &if_stmt.test,
1100                    file_path,
1101                    content,
1102                    line_index,
1103                    declared_params,
1104                    local_vars,
1105                    function_name,
1106                    function_line,
1107                );
1108                for stmt in &if_stmt.body {
1109                    self.visit_stmt_for_names(
1110                        stmt,
1111                        file_path,
1112                        content,
1113                        line_index,
1114                        declared_params,
1115                        local_vars,
1116                        function_name,
1117                        function_line,
1118                    );
1119                }
1120                for stmt in &if_stmt.orelse {
1121                    self.visit_stmt_for_names(
1122                        stmt,
1123                        file_path,
1124                        content,
1125                        line_index,
1126                        declared_params,
1127                        local_vars,
1128                        function_name,
1129                        function_line,
1130                    );
1131                }
1132            }
1133            Stmt::While(while_stmt) => {
1134                self.visit_expr_for_names(
1135                    &while_stmt.test,
1136                    file_path,
1137                    content,
1138                    line_index,
1139                    declared_params,
1140                    local_vars,
1141                    function_name,
1142                    function_line,
1143                );
1144                for stmt in &while_stmt.body {
1145                    self.visit_stmt_for_names(
1146                        stmt,
1147                        file_path,
1148                        content,
1149                        line_index,
1150                        declared_params,
1151                        local_vars,
1152                        function_name,
1153                        function_line,
1154                    );
1155                }
1156            }
1157            Stmt::For(for_stmt) => {
1158                self.visit_expr_for_names(
1159                    &for_stmt.iter,
1160                    file_path,
1161                    content,
1162                    line_index,
1163                    declared_params,
1164                    local_vars,
1165                    function_name,
1166                    function_line,
1167                );
1168                for stmt in &for_stmt.body {
1169                    self.visit_stmt_for_names(
1170                        stmt,
1171                        file_path,
1172                        content,
1173                        line_index,
1174                        declared_params,
1175                        local_vars,
1176                        function_name,
1177                        function_line,
1178                    );
1179                }
1180            }
1181            Stmt::With(with_stmt) => {
1182                for item in &with_stmt.items {
1183                    self.visit_expr_for_names(
1184                        &item.context_expr,
1185                        file_path,
1186                        content,
1187                        line_index,
1188                        declared_params,
1189                        local_vars,
1190                        function_name,
1191                        function_line,
1192                    );
1193                }
1194                for stmt in &with_stmt.body {
1195                    self.visit_stmt_for_names(
1196                        stmt,
1197                        file_path,
1198                        content,
1199                        line_index,
1200                        declared_params,
1201                        local_vars,
1202                        function_name,
1203                        function_line,
1204                    );
1205                }
1206            }
1207            Stmt::AsyncFor(for_stmt) => {
1208                self.visit_expr_for_names(
1209                    &for_stmt.iter,
1210                    file_path,
1211                    content,
1212                    line_index,
1213                    declared_params,
1214                    local_vars,
1215                    function_name,
1216                    function_line,
1217                );
1218                for stmt in &for_stmt.body {
1219                    self.visit_stmt_for_names(
1220                        stmt,
1221                        file_path,
1222                        content,
1223                        line_index,
1224                        declared_params,
1225                        local_vars,
1226                        function_name,
1227                        function_line,
1228                    );
1229                }
1230            }
1231            Stmt::AsyncWith(with_stmt) => {
1232                for item in &with_stmt.items {
1233                    self.visit_expr_for_names(
1234                        &item.context_expr,
1235                        file_path,
1236                        content,
1237                        line_index,
1238                        declared_params,
1239                        local_vars,
1240                        function_name,
1241                        function_line,
1242                    );
1243                }
1244                for stmt in &with_stmt.body {
1245                    self.visit_stmt_for_names(
1246                        stmt,
1247                        file_path,
1248                        content,
1249                        line_index,
1250                        declared_params,
1251                        local_vars,
1252                        function_name,
1253                        function_line,
1254                    );
1255                }
1256            }
1257            Stmt::Assert(assert_stmt) => {
1258                self.visit_expr_for_names(
1259                    &assert_stmt.test,
1260                    file_path,
1261                    content,
1262                    line_index,
1263                    declared_params,
1264                    local_vars,
1265                    function_name,
1266                    function_line,
1267                );
1268                if let Some(ref msg) = assert_stmt.msg {
1269                    self.visit_expr_for_names(
1270                        msg,
1271                        file_path,
1272                        content,
1273                        line_index,
1274                        declared_params,
1275                        local_vars,
1276                        function_name,
1277                        function_line,
1278                    );
1279                }
1280            }
1281            _ => {} // Other statement types
1282        }
1283    }
1284
1285    #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
1286    fn visit_expr_for_names(
1287        &self,
1288        expr: &Expr,
1289        file_path: &PathBuf,
1290        content: &str,
1291        line_index: &[usize],
1292        declared_params: &std::collections::HashSet<String>,
1293        local_vars: &std::collections::HashMap<String, usize>,
1294        function_name: &str,
1295        function_line: usize,
1296    ) {
1297        match expr {
1298            Expr::Name(name) => {
1299                let name_str = name.id.as_str();
1300                let line = self.get_line_from_offset(name.range.start().to_usize(), line_index);
1301
1302                // Check if this name is a known fixture and not a declared parameter
1303                // For local variables, only exclude them if they're defined BEFORE the current line
1304                // (Python variables are only in scope after they're assigned)
1305                let is_local_var_in_scope = local_vars
1306                    .get(name_str)
1307                    .map(|def_line| *def_line < line)
1308                    .unwrap_or(false);
1309
1310                if !declared_params.contains(name_str)
1311                    && !is_local_var_in_scope
1312                    && self.is_available_fixture(file_path, name_str)
1313                {
1314                    let start_char = self
1315                        .get_char_position_from_offset(name.range.start().to_usize(), line_index);
1316                    let end_char =
1317                        self.get_char_position_from_offset(name.range.end().to_usize(), line_index);
1318
1319                    info!(
1320                        "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1321                        name_str, file_path, line, start_char, function_name
1322                    );
1323
1324                    let undeclared = UndeclaredFixture {
1325                        name: name_str.to_string(),
1326                        file_path: file_path.clone(),
1327                        line,
1328                        start_char,
1329                        end_char,
1330                        function_name: function_name.to_string(),
1331                        function_line,
1332                    };
1333
1334                    self.undeclared_fixtures
1335                        .entry(file_path.clone())
1336                        .or_default()
1337                        .push(undeclared);
1338                }
1339            }
1340            Expr::Call(call) => {
1341                self.visit_expr_for_names(
1342                    &call.func,
1343                    file_path,
1344                    content,
1345                    line_index,
1346                    declared_params,
1347                    local_vars,
1348                    function_name,
1349                    function_line,
1350                );
1351                for arg in &call.args {
1352                    self.visit_expr_for_names(
1353                        arg,
1354                        file_path,
1355                        content,
1356                        line_index,
1357                        declared_params,
1358                        local_vars,
1359                        function_name,
1360                        function_line,
1361                    );
1362                }
1363            }
1364            Expr::Attribute(attr) => {
1365                self.visit_expr_for_names(
1366                    &attr.value,
1367                    file_path,
1368                    content,
1369                    line_index,
1370                    declared_params,
1371                    local_vars,
1372                    function_name,
1373                    function_line,
1374                );
1375            }
1376            Expr::BinOp(binop) => {
1377                self.visit_expr_for_names(
1378                    &binop.left,
1379                    file_path,
1380                    content,
1381                    line_index,
1382                    declared_params,
1383                    local_vars,
1384                    function_name,
1385                    function_line,
1386                );
1387                self.visit_expr_for_names(
1388                    &binop.right,
1389                    file_path,
1390                    content,
1391                    line_index,
1392                    declared_params,
1393                    local_vars,
1394                    function_name,
1395                    function_line,
1396                );
1397            }
1398            Expr::UnaryOp(unaryop) => {
1399                self.visit_expr_for_names(
1400                    &unaryop.operand,
1401                    file_path,
1402                    content,
1403                    line_index,
1404                    declared_params,
1405                    local_vars,
1406                    function_name,
1407                    function_line,
1408                );
1409            }
1410            Expr::Compare(compare) => {
1411                self.visit_expr_for_names(
1412                    &compare.left,
1413                    file_path,
1414                    content,
1415                    line_index,
1416                    declared_params,
1417                    local_vars,
1418                    function_name,
1419                    function_line,
1420                );
1421                for comparator in &compare.comparators {
1422                    self.visit_expr_for_names(
1423                        comparator,
1424                        file_path,
1425                        content,
1426                        line_index,
1427                        declared_params,
1428                        local_vars,
1429                        function_name,
1430                        function_line,
1431                    );
1432                }
1433            }
1434            Expr::Subscript(subscript) => {
1435                self.visit_expr_for_names(
1436                    &subscript.value,
1437                    file_path,
1438                    content,
1439                    line_index,
1440                    declared_params,
1441                    local_vars,
1442                    function_name,
1443                    function_line,
1444                );
1445                self.visit_expr_for_names(
1446                    &subscript.slice,
1447                    file_path,
1448                    content,
1449                    line_index,
1450                    declared_params,
1451                    local_vars,
1452                    function_name,
1453                    function_line,
1454                );
1455            }
1456            Expr::List(list) => {
1457                for elt in &list.elts {
1458                    self.visit_expr_for_names(
1459                        elt,
1460                        file_path,
1461                        content,
1462                        line_index,
1463                        declared_params,
1464                        local_vars,
1465                        function_name,
1466                        function_line,
1467                    );
1468                }
1469            }
1470            Expr::Tuple(tuple) => {
1471                for elt in &tuple.elts {
1472                    self.visit_expr_for_names(
1473                        elt,
1474                        file_path,
1475                        content,
1476                        line_index,
1477                        declared_params,
1478                        local_vars,
1479                        function_name,
1480                        function_line,
1481                    );
1482                }
1483            }
1484            Expr::Dict(dict) => {
1485                for k in dict.keys.iter().flatten() {
1486                    self.visit_expr_for_names(
1487                        k,
1488                        file_path,
1489                        content,
1490                        line_index,
1491                        declared_params,
1492                        local_vars,
1493                        function_name,
1494                        function_line,
1495                    );
1496                }
1497                for value in &dict.values {
1498                    self.visit_expr_for_names(
1499                        value,
1500                        file_path,
1501                        content,
1502                        line_index,
1503                        declared_params,
1504                        local_vars,
1505                        function_name,
1506                        function_line,
1507                    );
1508                }
1509            }
1510            Expr::Await(await_expr) => {
1511                // Handle await expressions (async functions)
1512                self.visit_expr_for_names(
1513                    &await_expr.value,
1514                    file_path,
1515                    content,
1516                    line_index,
1517                    declared_params,
1518                    local_vars,
1519                    function_name,
1520                    function_line,
1521                );
1522            }
1523            _ => {} // Other expression types
1524        }
1525    }
1526
1527    fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1528        // Check if this fixture exists and is available at this file location
1529        if let Some(definitions) = self.definitions.get(fixture_name) {
1530            // Check if any definition is available from this file location
1531            for def in definitions.iter() {
1532                // Fixture is available if it's in the same file or in a conftest.py in a parent directory
1533                if def.file_path == file_path {
1534                    return true;
1535                }
1536
1537                // Check if it's in a conftest.py in a parent directory
1538                if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1539                    && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1540                {
1541                    return true;
1542                }
1543
1544                // Check if it's in a virtual environment (third-party fixture)
1545                if def.file_path.to_string_lossy().contains("site-packages") {
1546                    return true;
1547                }
1548            }
1549        }
1550        false
1551    }
1552
1553    fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1554        // Python docstrings are the first statement in a function if it's an Expr containing a Constant string
1555        if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1556            if let Expr::Constant(constant) = &*expr_stmt.value {
1557                // Check if the constant is a string
1558                if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1559                    return Some(self.format_docstring(s.to_string()));
1560                }
1561            }
1562        }
1563        None
1564    }
1565
1566    fn format_docstring(&self, docstring: String) -> String {
1567        // Process docstring similar to Python's inspect.cleandoc()
1568        // 1. Split into lines
1569        let lines: Vec<&str> = docstring.lines().collect();
1570
1571        if lines.is_empty() {
1572            return String::new();
1573        }
1574
1575        // 2. Strip leading and trailing empty lines
1576        let mut start = 0;
1577        let mut end = lines.len();
1578
1579        while start < lines.len() && lines[start].trim().is_empty() {
1580            start += 1;
1581        }
1582
1583        while end > start && lines[end - 1].trim().is_empty() {
1584            end -= 1;
1585        }
1586
1587        if start >= end {
1588            return String::new();
1589        }
1590
1591        let lines = &lines[start..end];
1592
1593        // 3. Find minimum indentation (ignoring first line if it's not empty)
1594        let mut min_indent = usize::MAX;
1595        for (i, line) in lines.iter().enumerate() {
1596            if i == 0 && !line.trim().is_empty() {
1597                // First line might not be indented, skip it
1598                continue;
1599            }
1600
1601            if !line.trim().is_empty() {
1602                let indent = line.len() - line.trim_start().len();
1603                min_indent = min_indent.min(indent);
1604            }
1605        }
1606
1607        if min_indent == usize::MAX {
1608            min_indent = 0;
1609        }
1610
1611        // 4. Remove the common indentation from all lines (except possibly first)
1612        let mut result = Vec::new();
1613        for (i, line) in lines.iter().enumerate() {
1614            if i == 0 {
1615                // First line: just trim it
1616                result.push(line.trim().to_string());
1617            } else if line.trim().is_empty() {
1618                // Empty line: keep it empty
1619                result.push(String::new());
1620            } else {
1621                // Remove common indentation
1622                let dedented = if line.len() > min_indent {
1623                    &line[min_indent..]
1624                } else {
1625                    line.trim_start()
1626                };
1627                result.push(dedented.to_string());
1628            }
1629        }
1630
1631        // 5. Join lines back together
1632        result.join("\n")
1633    }
1634
1635    fn extract_return_type(
1636        &self,
1637        returns: &Option<Box<rustpython_parser::ast::Expr>>,
1638        body: &[Stmt],
1639        content: &str,
1640    ) -> Option<String> {
1641        if let Some(return_expr) = returns {
1642            // Check if the function body contains yield statements
1643            let has_yield = self.contains_yield(body);
1644
1645            if has_yield {
1646                // For generators, extract the yielded type from Generator[YieldType, ...]
1647                // or Iterator[YieldType] or similar
1648                return self.extract_yielded_type(return_expr, content);
1649            } else {
1650                // For regular functions, just return the type annotation as-is
1651                return Some(self.expr_to_string(return_expr, content));
1652            }
1653        }
1654        None
1655    }
1656
1657    #[allow(clippy::only_used_in_recursion)]
1658    fn contains_yield(&self, body: &[Stmt]) -> bool {
1659        for stmt in body {
1660            match stmt {
1661                Stmt::Expr(expr_stmt) => {
1662                    if let Expr::Yield(_) | Expr::YieldFrom(_) = &*expr_stmt.value {
1663                        return true;
1664                    }
1665                }
1666                Stmt::If(if_stmt) => {
1667                    if self.contains_yield(&if_stmt.body) || self.contains_yield(&if_stmt.orelse) {
1668                        return true;
1669                    }
1670                }
1671                Stmt::For(for_stmt) => {
1672                    if self.contains_yield(&for_stmt.body) || self.contains_yield(&for_stmt.orelse)
1673                    {
1674                        return true;
1675                    }
1676                }
1677                Stmt::While(while_stmt) => {
1678                    if self.contains_yield(&while_stmt.body)
1679                        || self.contains_yield(&while_stmt.orelse)
1680                    {
1681                        return true;
1682                    }
1683                }
1684                Stmt::With(with_stmt) => {
1685                    if self.contains_yield(&with_stmt.body) {
1686                        return true;
1687                    }
1688                }
1689                Stmt::Try(try_stmt) => {
1690                    if self.contains_yield(&try_stmt.body)
1691                        || self.contains_yield(&try_stmt.orelse)
1692                        || self.contains_yield(&try_stmt.finalbody)
1693                    {
1694                        return true;
1695                    }
1696                    // TODO: ExceptHandler struct doesn't expose body in rustpython-parser 0.4.0.
1697                    // Should be revisited if parser is upgraded.
1698                }
1699                _ => {}
1700            }
1701        }
1702        false
1703    }
1704
1705    fn extract_yielded_type(
1706        &self,
1707        expr: &rustpython_parser::ast::Expr,
1708        content: &str,
1709    ) -> Option<String> {
1710        // Handle Generator[YieldType, SendType, ReturnType] -> extract YieldType
1711        // Handle Iterator[YieldType] -> extract YieldType
1712        // Handle Iterable[YieldType] -> extract YieldType
1713        if let Expr::Subscript(subscript) = expr {
1714            // Get the base type name (Generator, Iterator, etc.)
1715            let _base_name = self.expr_to_string(&subscript.value, content);
1716
1717            // Extract the first type argument (the yield type)
1718            if let Expr::Tuple(tuple) = &*subscript.slice {
1719                if let Some(first_elem) = tuple.elts.first() {
1720                    return Some(self.expr_to_string(first_elem, content));
1721                }
1722            } else {
1723                // Single type argument (like Iterator[str])
1724                return Some(self.expr_to_string(&subscript.slice, content));
1725            }
1726        }
1727
1728        // If we can't extract the yielded type, return the whole annotation
1729        Some(self.expr_to_string(expr, content))
1730    }
1731
1732    #[allow(clippy::only_used_in_recursion)]
1733    fn expr_to_string(&self, expr: &rustpython_parser::ast::Expr, content: &str) -> String {
1734        match expr {
1735            Expr::Name(name) => name.id.to_string(),
1736            Expr::Attribute(attr) => {
1737                format!(
1738                    "{}.{}",
1739                    self.expr_to_string(&attr.value, content),
1740                    attr.attr
1741                )
1742            }
1743            Expr::Subscript(subscript) => {
1744                let base = self.expr_to_string(&subscript.value, content);
1745                let slice = self.expr_to_string(&subscript.slice, content);
1746                format!("{}[{}]", base, slice)
1747            }
1748            Expr::Tuple(tuple) => {
1749                let elements: Vec<String> = tuple
1750                    .elts
1751                    .iter()
1752                    .map(|e| self.expr_to_string(e, content))
1753                    .collect();
1754                elements.join(", ")
1755            }
1756            Expr::Constant(constant) => {
1757                format!("{:?}", constant.value)
1758            }
1759            Expr::BinOp(binop) if matches!(binop.op, rustpython_parser::ast::Operator::BitOr) => {
1760                // Handle union types like str | int
1761                format!(
1762                    "{} | {}",
1763                    self.expr_to_string(&binop.left, content),
1764                    self.expr_to_string(&binop.right, content)
1765                )
1766            }
1767            _ => {
1768                // Fallback for complex types we don't handle yet
1769                "Any".to_string()
1770            }
1771        }
1772    }
1773
1774    fn build_line_index(content: &str) -> Vec<usize> {
1775        let mut line_index = Vec::with_capacity(content.len() / 30);
1776        line_index.push(0);
1777        for (i, c) in content.char_indices() {
1778            if c == '\n' {
1779                line_index.push(i + 1);
1780            }
1781        }
1782        line_index
1783    }
1784
1785    fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
1786        match line_index.binary_search(&offset) {
1787            Ok(line) => line + 1,
1788            Err(line) => line,
1789        }
1790    }
1791
1792    fn get_char_position_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
1793        let line = self.get_line_from_offset(offset, line_index);
1794        let line_start = line_index[line - 1];
1795        offset.saturating_sub(line_start)
1796    }
1797
1798    /// Find fixture definition for a given position in a file
1799    pub fn find_fixture_definition(
1800        &self,
1801        file_path: &Path,
1802        line: u32,
1803        character: u32,
1804    ) -> Option<FixtureDefinition> {
1805        debug!(
1806            "find_fixture_definition: file={:?}, line={}, char={}",
1807            file_path, line, character
1808        );
1809
1810        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
1811
1812        // Read the file content - try cache first, then file system
1813        // Use Arc to avoid cloning large strings - just increments ref count
1814        let content = self.get_file_content(file_path)?;
1815
1816        // Avoid allocating Vec - access line directly via iterator
1817        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1818        debug!("Line content: {}", line_content);
1819
1820        // Extract the word at the character position
1821        let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1822        debug!("Word at cursor: {:?}", word_at_cursor);
1823
1824        // Check if we're inside a fixture definition with the same name (self-referencing)
1825        // In that case, we should skip the current definition and find the parent
1826        let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1827
1828        // First, check if this word matches any fixture usage on this line
1829        // AND that the cursor is within the character range of that usage
1830        if let Some(usages) = self.usages.get(file_path) {
1831            for usage in usages.iter() {
1832                if usage.line == target_line && usage.name == word_at_cursor {
1833                    // Check if cursor is within the character range of this usage
1834                    let cursor_pos = character as usize;
1835                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1836                        debug!(
1837                            "Cursor at {} is within usage range {}-{}: {}",
1838                            cursor_pos, usage.start_char, usage.end_char, usage.name
1839                        );
1840                        info!("Found fixture usage at cursor position: {}", usage.name);
1841
1842                        // If we're in a fixture definition with the same name, skip it when searching
1843                        if let Some(ref current_def) = current_fixture_def {
1844                            if current_def.name == word_at_cursor {
1845                                info!(
1846                                    "Self-referencing fixture detected, finding parent definition"
1847                                );
1848                                return self.find_closest_definition_excluding(
1849                                    file_path,
1850                                    &usage.name,
1851                                    Some(current_def),
1852                                );
1853                            }
1854                        }
1855
1856                        // Find the closest definition for this fixture
1857                        return self.find_closest_definition(file_path, &usage.name);
1858                    }
1859                }
1860            }
1861        }
1862
1863        debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1864        None
1865    }
1866
1867    /// Get the fixture definition at a specific line (if the line is a fixture definition)
1868    fn get_fixture_definition_at_line(
1869        &self,
1870        file_path: &Path,
1871        line: usize,
1872    ) -> Option<FixtureDefinition> {
1873        for entry in self.definitions.iter() {
1874            for def in entry.value().iter() {
1875                if def.file_path == file_path && def.line == line {
1876                    return Some(def.clone());
1877                }
1878            }
1879        }
1880        None
1881    }
1882
1883    /// Public method to get the fixture definition at a specific line and name
1884    /// Used when cursor is on a fixture definition line (not a usage)
1885    pub fn get_definition_at_line(
1886        &self,
1887        file_path: &Path,
1888        line: usize,
1889        fixture_name: &str,
1890    ) -> Option<FixtureDefinition> {
1891        if let Some(definitions) = self.definitions.get(fixture_name) {
1892            for def in definitions.iter() {
1893                if def.file_path == file_path && def.line == line {
1894                    return Some(def.clone());
1895                }
1896            }
1897        }
1898        None
1899    }
1900
1901    fn find_closest_definition(
1902        &self,
1903        file_path: &Path,
1904        fixture_name: &str,
1905    ) -> Option<FixtureDefinition> {
1906        let definitions = self.definitions.get(fixture_name)?;
1907
1908        // Priority 1: Check if fixture is defined in the same file (highest priority)
1909        // If multiple definitions exist in the same file, return the last one (pytest semantics)
1910        debug!(
1911            "Checking for fixture {} in same file: {:?}",
1912            fixture_name, file_path
1913        );
1914
1915        // Use iterator directly without collecting to Vec - more efficient
1916        if let Some(last_def) = definitions
1917            .iter()
1918            .filter(|def| def.file_path == file_path)
1919            .max_by_key(|def| def.line)
1920        {
1921            info!(
1922                "Found fixture {} in same file at line {} (using last definition)",
1923                fixture_name, last_def.line
1924            );
1925            return Some(last_def.clone());
1926        }
1927
1928        // Priority 2: Search upward through conftest.py files in parent directories
1929        // Start from the current file's directory and search upward
1930        let mut current_dir = file_path.parent()?;
1931
1932        debug!(
1933            "Searching for fixture {} in conftest.py files starting from {:?}",
1934            fixture_name, current_dir
1935        );
1936        loop {
1937            // Check for conftest.py in current directory
1938            let conftest_path = current_dir.join("conftest.py");
1939            debug!("  Checking conftest.py at: {:?}", conftest_path);
1940
1941            for def in definitions.iter() {
1942                if def.file_path == conftest_path {
1943                    info!(
1944                        "Found fixture {} in conftest.py: {:?}",
1945                        fixture_name, conftest_path
1946                    );
1947                    return Some(def.clone());
1948                }
1949            }
1950
1951            // Move up one directory
1952            match current_dir.parent() {
1953                Some(parent) => current_dir = parent,
1954                None => break,
1955            }
1956        }
1957
1958        // Priority 3: Check for third-party fixtures (from virtual environment)
1959        // These are fixtures from pytest plugins in site-packages
1960        debug!(
1961            "No fixture {} found in conftest hierarchy, checking for third-party fixtures",
1962            fixture_name
1963        );
1964        for def in definitions.iter() {
1965            if def.file_path.to_string_lossy().contains("site-packages") {
1966                info!(
1967                    "Found third-party fixture {} in site-packages: {:?}",
1968                    fixture_name, def.file_path
1969                );
1970                return Some(def.clone());
1971            }
1972        }
1973
1974        // Priority 4: If still no match, this means the fixture is defined somewhere
1975        // unrelated to the current file's hierarchy. This is unusual but can happen
1976        // when fixtures are defined in unrelated test directories.
1977        // Return the first definition sorted by path for determinism.
1978        warn!(
1979            "No fixture {} found following priority rules (same file, conftest hierarchy, third-party)",
1980            fixture_name
1981        );
1982        warn!(
1983            "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1984        );
1985
1986        let mut defs: Vec<_> = definitions.iter().cloned().collect();
1987        defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1988        defs.first().cloned()
1989    }
1990
1991    /// Find the closest definition for a fixture, excluding a specific definition
1992    /// This is useful for self-referencing fixtures where we need to find the parent definition
1993    fn find_closest_definition_excluding(
1994        &self,
1995        file_path: &Path,
1996        fixture_name: &str,
1997        exclude: Option<&FixtureDefinition>,
1998    ) -> Option<FixtureDefinition> {
1999        let definitions = self.definitions.get(fixture_name)?;
2000
2001        // Priority 1: Check if fixture is defined in the same file (highest priority)
2002        // but skip the excluded definition
2003        // If multiple definitions exist, use the last one (pytest semantics)
2004        debug!(
2005            "Checking for fixture {} in same file: {:?} (excluding: {:?})",
2006            fixture_name, file_path, exclude
2007        );
2008
2009        // Use iterator directly without collecting to Vec - more efficient
2010        if let Some(last_def) = definitions
2011            .iter()
2012            .filter(|def| {
2013                if def.file_path != file_path {
2014                    return false;
2015                }
2016                // Skip the excluded definition
2017                if let Some(excluded) = exclude {
2018                    if def == &excluded {
2019                        debug!("Skipping excluded definition at line {}", def.line);
2020                        return false;
2021                    }
2022                }
2023                true
2024            })
2025            .max_by_key(|def| def.line)
2026        {
2027            info!(
2028                "Found fixture {} in same file at line {} (using last definition, excluding specified)",
2029                fixture_name, last_def.line
2030            );
2031            return Some(last_def.clone());
2032        }
2033
2034        // Priority 2: Search upward through conftest.py files in parent directories
2035        let mut current_dir = file_path.parent()?;
2036
2037        debug!(
2038            "Searching for fixture {} in conftest.py files starting from {:?}",
2039            fixture_name, current_dir
2040        );
2041        loop {
2042            let conftest_path = current_dir.join("conftest.py");
2043            debug!("  Checking conftest.py at: {:?}", conftest_path);
2044
2045            for def in definitions.iter() {
2046                if def.file_path == conftest_path {
2047                    // Skip the excluded definition (though it's unlikely to be in a different file)
2048                    if let Some(excluded) = exclude {
2049                        if def == excluded {
2050                            debug!("Skipping excluded definition at line {}", def.line);
2051                            continue;
2052                        }
2053                    }
2054                    info!(
2055                        "Found fixture {} in conftest.py: {:?}",
2056                        fixture_name, conftest_path
2057                    );
2058                    return Some(def.clone());
2059                }
2060            }
2061
2062            // Move up one directory
2063            match current_dir.parent() {
2064                Some(parent) => current_dir = parent,
2065                None => break,
2066            }
2067        }
2068
2069        // Priority 3: Check for third-party fixtures (from virtual environment)
2070        debug!(
2071            "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
2072            fixture_name
2073        );
2074        for def in definitions.iter() {
2075            // Skip excluded definition
2076            if let Some(excluded) = exclude {
2077                if def == excluded {
2078                    continue;
2079                }
2080            }
2081            if def.file_path.to_string_lossy().contains("site-packages") {
2082                info!(
2083                    "Found third-party fixture {} in site-packages: {:?}",
2084                    fixture_name, def.file_path
2085                );
2086                return Some(def.clone());
2087            }
2088        }
2089
2090        // Priority 4: Deterministic fallback - return first definition by path (excluding specified)
2091        warn!(
2092            "No fixture {} found following priority rules (excluding specified)",
2093            fixture_name
2094        );
2095        warn!(
2096            "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
2097        );
2098
2099        let mut defs: Vec<_> = definitions
2100            .iter()
2101            .filter(|def| {
2102                if let Some(excluded) = exclude {
2103                    def != &excluded
2104                } else {
2105                    true
2106                }
2107            })
2108            .cloned()
2109            .collect();
2110        defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
2111        defs.first().cloned()
2112    }
2113
2114    /// Find the fixture name at a given position (either definition or usage)
2115    pub fn find_fixture_at_position(
2116        &self,
2117        file_path: &Path,
2118        line: u32,
2119        character: u32,
2120    ) -> Option<String> {
2121        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
2122
2123        debug!(
2124            "find_fixture_at_position: file={:?}, line={}, char={}",
2125            file_path, target_line, character
2126        );
2127
2128        // Read the file content - try cache first, then file system
2129        // Use Arc to avoid cloning large strings - just increments ref count
2130        let content = self.get_file_content(file_path)?;
2131
2132        // Avoid allocating Vec - access line directly via iterator
2133        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
2134        debug!("Line content: {}", line_content);
2135
2136        // Extract the word at the character position
2137        let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
2138        debug!("Word at cursor: {:?}", word_at_cursor);
2139
2140        // Check if this word matches any fixture usage on this line
2141        // AND that the cursor is within the character range of that usage
2142        if let Some(usages) = self.usages.get(file_path) {
2143            for usage in usages.iter() {
2144                if usage.line == target_line {
2145                    // Check if cursor is within the character range of this usage
2146                    let cursor_pos = character as usize;
2147                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
2148                        debug!(
2149                            "Cursor at {} is within usage range {}-{}: {}",
2150                            cursor_pos, usage.start_char, usage.end_char, usage.name
2151                        );
2152                        info!("Found fixture usage at cursor position: {}", usage.name);
2153                        return Some(usage.name.clone());
2154                    }
2155                }
2156            }
2157        }
2158
2159        // If no usage matched, check if we're on a fixture definition line
2160        // (but only if the cursor is NOT on a parameter name)
2161        for entry in self.definitions.iter() {
2162            for def in entry.value().iter() {
2163                if def.file_path == file_path && def.line == target_line {
2164                    // Check if the cursor is on the function name itself, not a parameter
2165                    if let Some(ref word) = word_at_cursor {
2166                        if word == &def.name {
2167                            info!(
2168                                "Found fixture definition name at cursor position: {}",
2169                                def.name
2170                            );
2171                            return Some(def.name.clone());
2172                        }
2173                    }
2174                    // If cursor is elsewhere on the definition line, don't return the fixture name
2175                    // unless it matches a parameter (which would be a usage)
2176                }
2177            }
2178        }
2179
2180        debug!("No fixture found at cursor position");
2181        None
2182    }
2183
2184    pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
2185        // Use char_indices to avoid Vec allocation - more efficient for hot path
2186        let char_indices: Vec<(usize, char)> = line.char_indices().collect();
2187
2188        // If cursor is beyond the line, return None
2189        if character >= char_indices.len() {
2190            return None;
2191        }
2192
2193        // Get the character at the cursor position
2194        let (_byte_pos, c) = char_indices[character];
2195
2196        // Check if cursor is ON an identifier character
2197        if c.is_alphanumeric() || c == '_' {
2198            // Find start of word (scan backwards)
2199            let mut start_idx = character;
2200            while start_idx > 0 {
2201                let (_, prev_c) = char_indices[start_idx - 1];
2202                if !prev_c.is_alphanumeric() && prev_c != '_' {
2203                    break;
2204                }
2205                start_idx -= 1;
2206            }
2207
2208            // Find end of word (scan forwards)
2209            let mut end_idx = character + 1;
2210            while end_idx < char_indices.len() {
2211                let (_, curr_c) = char_indices[end_idx];
2212                if !curr_c.is_alphanumeric() && curr_c != '_' {
2213                    break;
2214                }
2215                end_idx += 1;
2216            }
2217
2218            // Extract substring using byte positions
2219            let start_byte = char_indices[start_idx].0;
2220            let end_byte = if end_idx < char_indices.len() {
2221                char_indices[end_idx].0
2222            } else {
2223                line.len()
2224            };
2225
2226            return Some(line[start_byte..end_byte].to_string());
2227        }
2228
2229        None
2230    }
2231
2232    /// Find all references (usages) of a fixture by name
2233    pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
2234        info!("Finding all references for fixture: {}", fixture_name);
2235
2236        let mut all_references = Vec::new();
2237
2238        // Iterate through all files that have usages
2239        for entry in self.usages.iter() {
2240            let file_path = entry.key();
2241            let usages = entry.value();
2242
2243            // Find all usages of this fixture in this file
2244            for usage in usages.iter() {
2245                if usage.name == fixture_name {
2246                    debug!(
2247                        "Found reference to {} in {:?} at line {}",
2248                        fixture_name, file_path, usage.line
2249                    );
2250                    all_references.push(usage.clone());
2251                }
2252            }
2253        }
2254
2255        info!(
2256            "Found {} total references for fixture: {}",
2257            all_references.len(),
2258            fixture_name
2259        );
2260        all_references
2261    }
2262
2263    /// Find all references (usages) that would resolve to a specific fixture definition
2264    /// This respects the priority rules: same file > closest conftest.py > parent conftest.py
2265    ///
2266    /// For fixture overriding, this handles self-referencing parameters correctly:
2267    /// If a fixture parameter appears on the same line as a fixture definition with the same name,
2268    /// we exclude that definition when resolving, so it finds the parent instead.
2269    pub fn find_references_for_definition(
2270        &self,
2271        definition: &FixtureDefinition,
2272    ) -> Vec<FixtureUsage> {
2273        info!(
2274            "Finding references for specific definition: {} at {:?}:{}",
2275            definition.name, definition.file_path, definition.line
2276        );
2277
2278        let mut matching_references = Vec::new();
2279
2280        // Get all usages of this fixture name
2281        for entry in self.usages.iter() {
2282            let file_path = entry.key();
2283            let usages = entry.value();
2284
2285            for usage in usages.iter() {
2286                if usage.name == definition.name {
2287                    // Check if this usage is on the same line as a fixture definition with the same name
2288                    // (i.e., a self-referencing fixture parameter like "def foo(foo):")
2289                    let fixture_def_at_line =
2290                        self.get_fixture_definition_at_line(file_path, usage.line);
2291
2292                    let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
2293                        if current_def.name == usage.name {
2294                            // Self-referencing parameter - exclude current definition and find parent
2295                            debug!(
2296                                "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
2297                                file_path, usage.line, current_def.line
2298                            );
2299                            self.find_closest_definition_excluding(
2300                                file_path,
2301                                &usage.name,
2302                                Some(current_def),
2303                            )
2304                        } else {
2305                            // Different fixture - use normal resolution
2306                            self.find_closest_definition(file_path, &usage.name)
2307                        }
2308                    } else {
2309                        // Not on a fixture definition line - use normal resolution
2310                        self.find_closest_definition(file_path, &usage.name)
2311                    };
2312
2313                    if let Some(resolved_def) = resolved_def {
2314                        if resolved_def == *definition {
2315                            debug!(
2316                                "Usage at {:?}:{} resolves to our definition",
2317                                file_path, usage.line
2318                            );
2319                            matching_references.push(usage.clone());
2320                        } else {
2321                            debug!(
2322                                "Usage at {:?}:{} resolves to different definition at {:?}:{}",
2323                                file_path, usage.line, resolved_def.file_path, resolved_def.line
2324                            );
2325                        }
2326                    }
2327                }
2328            }
2329        }
2330
2331        info!(
2332            "Found {} references that resolve to this specific definition",
2333            matching_references.len()
2334        );
2335        matching_references
2336    }
2337
2338    /// Get all undeclared fixture usages for a file
2339    pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
2340        self.undeclared_fixtures
2341            .get(file_path)
2342            .map(|entry| entry.value().clone())
2343            .unwrap_or_default()
2344    }
2345
2346    /// Get all available fixtures for a given file, respecting pytest's fixture hierarchy
2347    /// Returns a list of fixture definitions sorted by name
2348    pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
2349        let mut available_fixtures = Vec::new();
2350        let mut seen_names = std::collections::HashSet::new();
2351
2352        // Priority 1: Fixtures in the same file
2353        for entry in self.definitions.iter() {
2354            let fixture_name = entry.key();
2355            for def in entry.value().iter() {
2356                if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
2357                    available_fixtures.push(def.clone());
2358                    seen_names.insert(fixture_name.clone());
2359                }
2360            }
2361        }
2362
2363        // Priority 2: Fixtures in conftest.py files (walking up the directory tree)
2364        if let Some(mut current_dir) = file_path.parent() {
2365            loop {
2366                let conftest_path = current_dir.join("conftest.py");
2367
2368                for entry in self.definitions.iter() {
2369                    let fixture_name = entry.key();
2370                    for def in entry.value().iter() {
2371                        if def.file_path == conftest_path
2372                            && !seen_names.contains(fixture_name.as_str())
2373                        {
2374                            available_fixtures.push(def.clone());
2375                            seen_names.insert(fixture_name.clone());
2376                        }
2377                    }
2378                }
2379
2380                // Move up one directory
2381                match current_dir.parent() {
2382                    Some(parent) => current_dir = parent,
2383                    None => break,
2384                }
2385            }
2386        }
2387
2388        // Priority 3: Third-party fixtures from site-packages
2389        for entry in self.definitions.iter() {
2390            let fixture_name = entry.key();
2391            for def in entry.value().iter() {
2392                if def.file_path.to_string_lossy().contains("site-packages")
2393                    && !seen_names.contains(fixture_name.as_str())
2394                {
2395                    available_fixtures.push(def.clone());
2396                    seen_names.insert(fixture_name.clone());
2397                }
2398            }
2399        }
2400
2401        // Sort by name for consistent ordering
2402        available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
2403        available_fixtures
2404    }
2405
2406    /// Check if a position is inside a test or fixture function (parameter or body)
2407    /// Returns Some((function_name, is_fixture, declared_params)) if inside a function
2408    pub fn is_inside_function(
2409        &self,
2410        file_path: &Path,
2411        line: u32,
2412        character: u32,
2413    ) -> Option<(String, bool, Vec<String>)> {
2414        // Try cache first, then file system
2415        let content = self.get_file_content(file_path)?;
2416
2417        let target_line = (line + 1) as usize; // Convert to 1-based
2418
2419        // Parse the file
2420        let parsed = parse(&content, Mode::Module, "").ok()?;
2421
2422        if let rustpython_parser::ast::Mod::Module(module) = parsed {
2423            return self.find_enclosing_function(
2424                &module.body,
2425                &content,
2426                target_line,
2427                character as usize,
2428            );
2429        }
2430
2431        None
2432    }
2433
2434    fn find_enclosing_function(
2435        &self,
2436        stmts: &[Stmt],
2437        content: &str,
2438        target_line: usize,
2439        _target_char: usize,
2440    ) -> Option<(String, bool, Vec<String>)> {
2441        for stmt in stmts {
2442            match stmt {
2443                Stmt::FunctionDef(func_def) => {
2444                    let func_start_line = content[..func_def.range.start().to_usize()]
2445                        .matches('\n')
2446                        .count()
2447                        + 1;
2448                    let func_end_line = content[..func_def.range.end().to_usize()]
2449                        .matches('\n')
2450                        .count()
2451                        + 1;
2452
2453                    // Check if target is within this function's range
2454                    if target_line >= func_start_line && target_line <= func_end_line {
2455                        let is_fixture = func_def
2456                            .decorator_list
2457                            .iter()
2458                            .any(Self::is_fixture_decorator);
2459                        let is_test = func_def.name.starts_with("test_");
2460
2461                        // Only return if it's a test or fixture
2462                        if is_test || is_fixture {
2463                            let params: Vec<String> = func_def
2464                                .args
2465                                .args
2466                                .iter()
2467                                .map(|arg| arg.def.arg.to_string())
2468                                .collect();
2469
2470                            return Some((func_def.name.to_string(), is_fixture, params));
2471                        }
2472                    }
2473                }
2474                Stmt::AsyncFunctionDef(func_def) => {
2475                    let func_start_line = content[..func_def.range.start().to_usize()]
2476                        .matches('\n')
2477                        .count()
2478                        + 1;
2479                    let func_end_line = content[..func_def.range.end().to_usize()]
2480                        .matches('\n')
2481                        .count()
2482                        + 1;
2483
2484                    if target_line >= func_start_line && target_line <= func_end_line {
2485                        let is_fixture = func_def
2486                            .decorator_list
2487                            .iter()
2488                            .any(Self::is_fixture_decorator);
2489                        let is_test = func_def.name.starts_with("test_");
2490
2491                        if is_test || is_fixture {
2492                            let params: Vec<String> = func_def
2493                                .args
2494                                .args
2495                                .iter()
2496                                .map(|arg| arg.def.arg.to_string())
2497                                .collect();
2498
2499                            return Some((func_def.name.to_string(), is_fixture, params));
2500                        }
2501                    }
2502                }
2503                _ => {}
2504            }
2505        }
2506
2507        None
2508    }
2509
2510    /// Print fixtures as a tree structure
2511    /// Shows directory hierarchy with fixtures defined in each file
2512    pub fn print_fixtures_tree(&self, root_path: &Path, skip_unused: bool, only_unused: bool) {
2513        // Collect all files that define fixtures
2514        let mut file_fixtures: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
2515
2516        for entry in self.definitions.iter() {
2517            let fixture_name = entry.key();
2518            let definitions = entry.value();
2519
2520            for def in definitions {
2521                file_fixtures
2522                    .entry(def.file_path.clone())
2523                    .or_default()
2524                    .insert(fixture_name.clone());
2525            }
2526        }
2527
2528        // Count fixture usages
2529        let mut fixture_usage_counts: HashMap<String, usize> = HashMap::new();
2530        for entry in self.usages.iter() {
2531            let usages = entry.value();
2532            for usage in usages {
2533                *fixture_usage_counts.entry(usage.name.clone()).or_insert(0) += 1;
2534            }
2535        }
2536
2537        // Build a tree structure from paths
2538        let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
2539        let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
2540
2541        for file_path in file_fixtures.keys() {
2542            all_paths.insert(file_path.clone());
2543
2544            // Add all parent directories
2545            let mut current = file_path.as_path();
2546            while let Some(parent) = current.parent() {
2547                if parent == root_path || parent.as_os_str().is_empty() {
2548                    break;
2549                }
2550                all_paths.insert(parent.to_path_buf());
2551                current = parent;
2552            }
2553        }
2554
2555        // Build parent-child relationships
2556        for path in &all_paths {
2557            if let Some(parent) = path.parent() {
2558                if parent != root_path && !parent.as_os_str().is_empty() {
2559                    tree.entry(parent.to_path_buf())
2560                        .or_default()
2561                        .push(path.clone());
2562                }
2563            }
2564        }
2565
2566        // Sort children in each directory
2567        for children in tree.values_mut() {
2568            children.sort();
2569        }
2570
2571        // Print the tree
2572        println!("Fixtures tree for: {}", root_path.display());
2573        println!();
2574
2575        if file_fixtures.is_empty() {
2576            println!("No fixtures found in this directory.");
2577            return;
2578        }
2579
2580        // Find top-level items (direct children of root)
2581        let mut top_level: Vec<PathBuf> = all_paths
2582            .iter()
2583            .filter(|p| {
2584                if let Some(parent) = p.parent() {
2585                    parent == root_path
2586                } else {
2587                    false
2588                }
2589            })
2590            .cloned()
2591            .collect();
2592        top_level.sort();
2593
2594        for (i, path) in top_level.iter().enumerate() {
2595            let is_last = i == top_level.len() - 1;
2596            self.print_tree_node(
2597                path,
2598                &file_fixtures,
2599                &tree,
2600                "",
2601                is_last,
2602                true, // is_root_level
2603                &fixture_usage_counts,
2604                skip_unused,
2605                only_unused,
2606            );
2607        }
2608    }
2609
2610    #[allow(clippy::too_many_arguments)]
2611    #[allow(clippy::only_used_in_recursion)]
2612    fn print_tree_node(
2613        &self,
2614        path: &Path,
2615        file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2616        tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2617        prefix: &str,
2618        is_last: bool,
2619        is_root_level: bool,
2620        fixture_usage_counts: &HashMap<String, usize>,
2621        skip_unused: bool,
2622        only_unused: bool,
2623    ) {
2624        use colored::Colorize;
2625        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
2626
2627        // Print current node
2628        let connector = if is_root_level {
2629            "" // No connector for root level
2630        } else if is_last {
2631            "└── "
2632        } else {
2633            "├── "
2634        };
2635
2636        if path.is_file() {
2637            // Print file with fixtures
2638            if let Some(fixtures) = file_fixtures.get(path) {
2639                // Filter fixtures based on flags
2640                let fixture_vec: Vec<_> = fixtures
2641                    .iter()
2642                    .filter(|fixture_name| {
2643                        let usage_count = fixture_usage_counts
2644                            .get(*fixture_name)
2645                            .copied()
2646                            .unwrap_or(0);
2647                        if only_unused {
2648                            usage_count == 0
2649                        } else if skip_unused {
2650                            usage_count > 0
2651                        } else {
2652                            true
2653                        }
2654                    })
2655                    .collect();
2656
2657                // Skip this file if no fixtures match the filter
2658                if fixture_vec.is_empty() {
2659                    return;
2660                }
2661
2662                let file_display = name.to_string().cyan().bold();
2663                println!(
2664                    "{}{}{} ({} fixtures)",
2665                    prefix,
2666                    connector,
2667                    file_display,
2668                    fixture_vec.len()
2669                );
2670
2671                // Print fixtures in this file
2672                let new_prefix = if is_root_level {
2673                    "".to_string()
2674                } else {
2675                    format!("{}{}", prefix, if is_last { "    " } else { "│   " })
2676                };
2677
2678                for (j, fixture_name) in fixture_vec.iter().enumerate() {
2679                    let is_last_fixture = j == fixture_vec.len() - 1;
2680                    let fixture_connector = if is_last_fixture {
2681                        "└── "
2682                    } else {
2683                        "├── "
2684                    };
2685
2686                    // Get usage count for this fixture
2687                    let usage_count = fixture_usage_counts
2688                        .get(*fixture_name)
2689                        .copied()
2690                        .unwrap_or(0);
2691
2692                    // Format the fixture name with color based on usage
2693                    let fixture_display = if usage_count == 0 {
2694                        // Unused fixture - show in dim/gray
2695                        fixture_name.to_string().dimmed()
2696                    } else {
2697                        // Used fixture - show in green
2698                        fixture_name.to_string().green()
2699                    };
2700
2701                    // Format usage count
2702                    let usage_info = if usage_count == 0 {
2703                        "unused".dimmed().to_string()
2704                    } else if usage_count == 1 {
2705                        format!("{}", "used 1 time".yellow())
2706                    } else {
2707                        format!("{}", format!("used {} times", usage_count).yellow())
2708                    };
2709
2710                    println!(
2711                        "{}{}{} ({})",
2712                        new_prefix, fixture_connector, fixture_display, usage_info
2713                    );
2714                }
2715            } else {
2716                println!("{}{}{}", prefix, connector, name);
2717            }
2718        } else {
2719            // Print directory - but first check if it has any visible children
2720            if let Some(children) = tree.get(path) {
2721                // Check if any children will be visible
2722                let has_visible_children = children.iter().any(|child| {
2723                    Self::has_visible_fixtures(
2724                        child,
2725                        file_fixtures,
2726                        tree,
2727                        fixture_usage_counts,
2728                        skip_unused,
2729                        only_unused,
2730                    )
2731                });
2732
2733                if !has_visible_children {
2734                    return;
2735                }
2736
2737                let dir_display = format!("{}/", name).blue().bold();
2738                println!("{}{}{}", prefix, connector, dir_display);
2739
2740                let new_prefix = if is_root_level {
2741                    "".to_string()
2742                } else {
2743                    format!("{}{}", prefix, if is_last { "    " } else { "│   " })
2744                };
2745
2746                for (j, child) in children.iter().enumerate() {
2747                    let is_last_child = j == children.len() - 1;
2748                    self.print_tree_node(
2749                        child,
2750                        file_fixtures,
2751                        tree,
2752                        &new_prefix,
2753                        is_last_child,
2754                        false, // is_root_level
2755                        fixture_usage_counts,
2756                        skip_unused,
2757                        only_unused,
2758                    );
2759                }
2760            }
2761        }
2762    }
2763
2764    fn has_visible_fixtures(
2765        path: &Path,
2766        file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2767        tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2768        fixture_usage_counts: &HashMap<String, usize>,
2769        skip_unused: bool,
2770        only_unused: bool,
2771    ) -> bool {
2772        if path.is_file() {
2773            // Check if this file has any fixtures matching the filter
2774            if let Some(fixtures) = file_fixtures.get(path) {
2775                return fixtures.iter().any(|fixture_name| {
2776                    let usage_count = fixture_usage_counts.get(fixture_name).copied().unwrap_or(0);
2777                    if only_unused {
2778                        usage_count == 0
2779                    } else if skip_unused {
2780                        usage_count > 0
2781                    } else {
2782                        true
2783                    }
2784                });
2785            }
2786            false
2787        } else {
2788            // Check if any children have visible fixtures
2789            if let Some(children) = tree.get(path) {
2790                children.iter().any(|child| {
2791                    Self::has_visible_fixtures(
2792                        child,
2793                        file_fixtures,
2794                        tree,
2795                        fixture_usage_counts,
2796                        skip_unused,
2797                        only_unused,
2798                    )
2799                })
2800            } else {
2801                false
2802            }
2803        }
2804    }
2805}