pytest_language_server/
fixtures.rs

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