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        // No fixture found in scope - this is intentional, not a fallback.
2353        // A fixture must be in: same file, conftest.py hierarchy, or site-packages
2354        // to be accessible from the requesting file.
2355        debug!(
2356            "No fixture {} found in scope for {:?} (same file, conftest hierarchy, or third-party)",
2357            fixture_name, file_path
2358        );
2359        None
2360    }
2361
2362    /// Find the closest definition for a fixture, excluding a specific definition
2363    /// This is useful for self-referencing fixtures where we need to find the parent definition
2364    fn find_closest_definition_excluding(
2365        &self,
2366        file_path: &Path,
2367        fixture_name: &str,
2368        exclude: Option<&FixtureDefinition>,
2369    ) -> Option<FixtureDefinition> {
2370        let definitions = self.definitions.get(fixture_name)?;
2371
2372        // Priority 1: Check if fixture is defined in the same file (highest priority)
2373        // but skip the excluded definition
2374        // If multiple definitions exist, use the last one (pytest semantics)
2375        debug!(
2376            "Checking for fixture {} in same file: {:?} (excluding: {:?})",
2377            fixture_name, file_path, exclude
2378        );
2379
2380        // Use iterator directly without collecting to Vec - more efficient
2381        if let Some(last_def) = definitions
2382            .iter()
2383            .filter(|def| {
2384                if def.file_path != file_path {
2385                    return false;
2386                }
2387                // Skip the excluded definition
2388                if let Some(excluded) = exclude {
2389                    if def == &excluded {
2390                        debug!("Skipping excluded definition at line {}", def.line);
2391                        return false;
2392                    }
2393                }
2394                true
2395            })
2396            .max_by_key(|def| def.line)
2397        {
2398            info!(
2399                "Found fixture {} in same file at line {} (using last definition, excluding specified)",
2400                fixture_name, last_def.line
2401            );
2402            return Some(last_def.clone());
2403        }
2404
2405        // Priority 2: Search upward through conftest.py files in parent directories
2406        let mut current_dir = file_path.parent()?;
2407
2408        debug!(
2409            "Searching for fixture {} in conftest.py files starting from {:?}",
2410            fixture_name, current_dir
2411        );
2412        loop {
2413            let conftest_path = current_dir.join("conftest.py");
2414            debug!("  Checking conftest.py at: {:?}", conftest_path);
2415
2416            for def in definitions.iter() {
2417                if def.file_path == conftest_path {
2418                    // Skip the excluded definition (though it's unlikely to be in a different file)
2419                    if let Some(excluded) = exclude {
2420                        if def == excluded {
2421                            debug!("Skipping excluded definition at line {}", def.line);
2422                            continue;
2423                        }
2424                    }
2425                    info!(
2426                        "Found fixture {} in conftest.py: {:?}",
2427                        fixture_name, conftest_path
2428                    );
2429                    return Some(def.clone());
2430                }
2431            }
2432
2433            // Move up one directory
2434            match current_dir.parent() {
2435                Some(parent) => current_dir = parent,
2436                None => break,
2437            }
2438        }
2439
2440        // Priority 3: Check for third-party fixtures (from virtual environment)
2441        debug!(
2442            "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
2443            fixture_name
2444        );
2445        for def in definitions.iter() {
2446            // Skip excluded definition
2447            if let Some(excluded) = exclude {
2448                if def == excluded {
2449                    continue;
2450                }
2451            }
2452            if def.file_path.to_string_lossy().contains("site-packages") {
2453                info!(
2454                    "Found third-party fixture {} in site-packages: {:?}",
2455                    fixture_name, def.file_path
2456                );
2457                return Some(def.clone());
2458            }
2459        }
2460
2461        // No fixture found in scope - this is intentional, not a fallback.
2462        // A fixture must be in: same file, conftest.py hierarchy, or site-packages
2463        // to be accessible from the requesting file.
2464        debug!(
2465            "No fixture {} found in scope for {:?} (excluding specified definition)",
2466            fixture_name, file_path
2467        );
2468        None
2469    }
2470
2471    /// Find the fixture name at a given position (either definition or usage)
2472    pub fn find_fixture_at_position(
2473        &self,
2474        file_path: &Path,
2475        line: u32,
2476        character: u32,
2477    ) -> Option<String> {
2478        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
2479
2480        debug!(
2481            "find_fixture_at_position: file={:?}, line={}, char={}",
2482            file_path, target_line, character
2483        );
2484
2485        // Read the file content - try cache first, then file system
2486        // Use Arc to avoid cloning large strings - just increments ref count
2487        let content = self.get_file_content(file_path)?;
2488
2489        // Avoid allocating Vec - access line directly via iterator
2490        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
2491        debug!("Line content: {}", line_content);
2492
2493        // Extract the word at the character position
2494        let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
2495        debug!("Word at cursor: {:?}", word_at_cursor);
2496
2497        // Check if this word matches any fixture usage on this line
2498        // AND that the cursor is within the character range of that usage
2499        if let Some(usages) = self.usages.get(file_path) {
2500            for usage in usages.iter() {
2501                if usage.line == target_line {
2502                    // Check if cursor is within the character range of this usage
2503                    let cursor_pos = character as usize;
2504                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
2505                        debug!(
2506                            "Cursor at {} is within usage range {}-{}: {}",
2507                            cursor_pos, usage.start_char, usage.end_char, usage.name
2508                        );
2509                        info!("Found fixture usage at cursor position: {}", usage.name);
2510                        return Some(usage.name.clone());
2511                    }
2512                }
2513            }
2514        }
2515
2516        // If no usage matched, check if we're on a fixture definition line
2517        // (but only if the cursor is NOT on a parameter name)
2518        for entry in self.definitions.iter() {
2519            for def in entry.value().iter() {
2520                if def.file_path == file_path && def.line == target_line {
2521                    // Check if the cursor is on the function name itself, not a parameter
2522                    if let Some(ref word) = word_at_cursor {
2523                        if word == &def.name {
2524                            info!(
2525                                "Found fixture definition name at cursor position: {}",
2526                                def.name
2527                            );
2528                            return Some(def.name.clone());
2529                        }
2530                    }
2531                    // If cursor is elsewhere on the definition line, don't return the fixture name
2532                    // unless it matches a parameter (which would be a usage)
2533                }
2534            }
2535        }
2536
2537        debug!("No fixture found at cursor position");
2538        None
2539    }
2540
2541    pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
2542        // Use char_indices to avoid Vec allocation - more efficient for hot path
2543        let char_indices: Vec<(usize, char)> = line.char_indices().collect();
2544
2545        // If cursor is beyond the line, return None
2546        if character >= char_indices.len() {
2547            return None;
2548        }
2549
2550        // Get the character at the cursor position
2551        let (_byte_pos, c) = char_indices[character];
2552
2553        // Check if cursor is ON an identifier character
2554        if c.is_alphanumeric() || c == '_' {
2555            // Find start of word (scan backwards)
2556            let mut start_idx = character;
2557            while start_idx > 0 {
2558                let (_, prev_c) = char_indices[start_idx - 1];
2559                if !prev_c.is_alphanumeric() && prev_c != '_' {
2560                    break;
2561                }
2562                start_idx -= 1;
2563            }
2564
2565            // Find end of word (scan forwards)
2566            let mut end_idx = character + 1;
2567            while end_idx < char_indices.len() {
2568                let (_, curr_c) = char_indices[end_idx];
2569                if !curr_c.is_alphanumeric() && curr_c != '_' {
2570                    break;
2571                }
2572                end_idx += 1;
2573            }
2574
2575            // Extract substring using byte positions
2576            let start_byte = char_indices[start_idx].0;
2577            let end_byte = if end_idx < char_indices.len() {
2578                char_indices[end_idx].0
2579            } else {
2580                line.len()
2581            };
2582
2583            return Some(line[start_byte..end_byte].to_string());
2584        }
2585
2586        None
2587    }
2588
2589    /// Find all references (usages) of a fixture by name
2590    pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
2591        info!("Finding all references for fixture: {}", fixture_name);
2592
2593        let mut all_references = Vec::new();
2594
2595        // Iterate through all files that have usages
2596        for entry in self.usages.iter() {
2597            let file_path = entry.key();
2598            let usages = entry.value();
2599
2600            // Find all usages of this fixture in this file
2601            for usage in usages.iter() {
2602                if usage.name == fixture_name {
2603                    debug!(
2604                        "Found reference to {} in {:?} at line {}",
2605                        fixture_name, file_path, usage.line
2606                    );
2607                    all_references.push(usage.clone());
2608                }
2609            }
2610        }
2611
2612        info!(
2613            "Found {} total references for fixture: {}",
2614            all_references.len(),
2615            fixture_name
2616        );
2617        all_references
2618    }
2619
2620    /// Find all references (usages) that would resolve to a specific fixture definition
2621    /// This respects the priority rules: same file > closest conftest.py > parent conftest.py
2622    ///
2623    /// For fixture overriding, this handles self-referencing parameters correctly:
2624    /// If a fixture parameter appears on the same line as a fixture definition with the same name,
2625    /// we exclude that definition when resolving, so it finds the parent instead.
2626    pub fn find_references_for_definition(
2627        &self,
2628        definition: &FixtureDefinition,
2629    ) -> Vec<FixtureUsage> {
2630        info!(
2631            "Finding references for specific definition: {} at {:?}:{}",
2632            definition.name, definition.file_path, definition.line
2633        );
2634
2635        let mut matching_references = Vec::new();
2636
2637        // Get all usages of this fixture name
2638        for entry in self.usages.iter() {
2639            let file_path = entry.key();
2640            let usages = entry.value();
2641
2642            for usage in usages.iter() {
2643                if usage.name == definition.name {
2644                    // Check if this usage is on the same line as a fixture definition with the same name
2645                    // (i.e., a self-referencing fixture parameter like "def foo(foo):")
2646                    let fixture_def_at_line =
2647                        self.get_fixture_definition_at_line(file_path, usage.line);
2648
2649                    let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
2650                        if current_def.name == usage.name {
2651                            // Self-referencing parameter - exclude current definition and find parent
2652                            debug!(
2653                                "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
2654                                file_path, usage.line, current_def.line
2655                            );
2656                            self.find_closest_definition_excluding(
2657                                file_path,
2658                                &usage.name,
2659                                Some(current_def),
2660                            )
2661                        } else {
2662                            // Different fixture - use normal resolution
2663                            self.find_closest_definition(file_path, &usage.name)
2664                        }
2665                    } else {
2666                        // Not on a fixture definition line - use normal resolution
2667                        self.find_closest_definition(file_path, &usage.name)
2668                    };
2669
2670                    if let Some(resolved_def) = resolved_def {
2671                        if resolved_def == *definition {
2672                            debug!(
2673                                "Usage at {:?}:{} resolves to our definition",
2674                                file_path, usage.line
2675                            );
2676                            matching_references.push(usage.clone());
2677                        } else {
2678                            debug!(
2679                                "Usage at {:?}:{} resolves to different definition at {:?}:{}",
2680                                file_path, usage.line, resolved_def.file_path, resolved_def.line
2681                            );
2682                        }
2683                    }
2684                }
2685            }
2686        }
2687
2688        info!(
2689            "Found {} references that resolve to this specific definition",
2690            matching_references.len()
2691        );
2692        matching_references
2693    }
2694
2695    /// Get all undeclared fixture usages for a file
2696    pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
2697        self.undeclared_fixtures
2698            .get(file_path)
2699            .map(|entry| entry.value().clone())
2700            .unwrap_or_default()
2701    }
2702
2703    /// Get all available fixtures for a given file, respecting pytest's fixture hierarchy
2704    /// Returns a list of fixture definitions sorted by name
2705    pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
2706        let mut available_fixtures = Vec::new();
2707        let mut seen_names = std::collections::HashSet::new();
2708
2709        // Priority 1: Fixtures in the same file
2710        for entry in self.definitions.iter() {
2711            let fixture_name = entry.key();
2712            for def in entry.value().iter() {
2713                if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
2714                    available_fixtures.push(def.clone());
2715                    seen_names.insert(fixture_name.clone());
2716                }
2717            }
2718        }
2719
2720        // Priority 2: Fixtures in conftest.py files (walking up the directory tree)
2721        if let Some(mut current_dir) = file_path.parent() {
2722            loop {
2723                let conftest_path = current_dir.join("conftest.py");
2724
2725                for entry in self.definitions.iter() {
2726                    let fixture_name = entry.key();
2727                    for def in entry.value().iter() {
2728                        if def.file_path == conftest_path
2729                            && !seen_names.contains(fixture_name.as_str())
2730                        {
2731                            available_fixtures.push(def.clone());
2732                            seen_names.insert(fixture_name.clone());
2733                        }
2734                    }
2735                }
2736
2737                // Move up one directory
2738                match current_dir.parent() {
2739                    Some(parent) => current_dir = parent,
2740                    None => break,
2741                }
2742            }
2743        }
2744
2745        // Priority 3: Third-party fixtures from site-packages
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.to_string_lossy().contains("site-packages")
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        // Sort by name for consistent ordering
2759        available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
2760        available_fixtures
2761    }
2762
2763    /// Check if a position is inside a test or fixture function (parameter or body)
2764    /// Returns Some((function_name, is_fixture, declared_params)) if inside a function
2765    pub fn is_inside_function(
2766        &self,
2767        file_path: &Path,
2768        line: u32,
2769        character: u32,
2770    ) -> Option<(String, bool, Vec<String>)> {
2771        // Try cache first, then file system
2772        let content = self.get_file_content(file_path)?;
2773
2774        let target_line = (line + 1) as usize; // Convert to 1-based
2775
2776        // Parse the file
2777        let parsed = parse(&content, Mode::Module, "").ok()?;
2778
2779        if let rustpython_parser::ast::Mod::Module(module) = parsed {
2780            return self.find_enclosing_function(
2781                &module.body,
2782                &content,
2783                target_line,
2784                character as usize,
2785            );
2786        }
2787
2788        None
2789    }
2790
2791    fn find_enclosing_function(
2792        &self,
2793        stmts: &[Stmt],
2794        content: &str,
2795        target_line: usize,
2796        _target_char: usize,
2797    ) -> Option<(String, bool, Vec<String>)> {
2798        for stmt in stmts {
2799            match stmt {
2800                Stmt::FunctionDef(func_def) => {
2801                    let func_start_line = content[..func_def.range.start().to_usize()]
2802                        .matches('\n')
2803                        .count()
2804                        + 1;
2805                    let func_end_line = content[..func_def.range.end().to_usize()]
2806                        .matches('\n')
2807                        .count()
2808                        + 1;
2809
2810                    // Check if target is within this function's range
2811                    if target_line >= func_start_line && target_line <= func_end_line {
2812                        let is_fixture = func_def
2813                            .decorator_list
2814                            .iter()
2815                            .any(Self::is_fixture_decorator);
2816                        let is_test = func_def.name.starts_with("test_");
2817
2818                        // Only return if it's a test or fixture
2819                        if is_test || is_fixture {
2820                            let params: Vec<String> = func_def
2821                                .args
2822                                .args
2823                                .iter()
2824                                .map(|arg| arg.def.arg.to_string())
2825                                .collect();
2826
2827                            return Some((func_def.name.to_string(), is_fixture, params));
2828                        }
2829                    }
2830                }
2831                Stmt::AsyncFunctionDef(func_def) => {
2832                    let func_start_line = content[..func_def.range.start().to_usize()]
2833                        .matches('\n')
2834                        .count()
2835                        + 1;
2836                    let func_end_line = content[..func_def.range.end().to_usize()]
2837                        .matches('\n')
2838                        .count()
2839                        + 1;
2840
2841                    if target_line >= func_start_line && target_line <= func_end_line {
2842                        let is_fixture = func_def
2843                            .decorator_list
2844                            .iter()
2845                            .any(Self::is_fixture_decorator);
2846                        let is_test = func_def.name.starts_with("test_");
2847
2848                        if is_test || is_fixture {
2849                            let params: Vec<String> = func_def
2850                                .args
2851                                .args
2852                                .iter()
2853                                .map(|arg| arg.def.arg.to_string())
2854                                .collect();
2855
2856                            return Some((func_def.name.to_string(), is_fixture, params));
2857                        }
2858                    }
2859                }
2860                _ => {}
2861            }
2862        }
2863
2864        None
2865    }
2866
2867    /// Print fixtures as a tree structure
2868    /// Shows directory hierarchy with fixtures defined in each file
2869    pub fn print_fixtures_tree(&self, root_path: &Path, skip_unused: bool, only_unused: bool) {
2870        // Collect all files that define fixtures
2871        let mut file_fixtures: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
2872
2873        for entry in self.definitions.iter() {
2874            let fixture_name = entry.key();
2875            let definitions = entry.value();
2876
2877            for def in definitions {
2878                file_fixtures
2879                    .entry(def.file_path.clone())
2880                    .or_default()
2881                    .insert(fixture_name.clone());
2882            }
2883        }
2884
2885        // Count fixture usages using scoped reference counting (Issue #23 fix)
2886        // Each definition's usage count is based on references that actually resolve to it,
2887        // not just any usage of the same fixture name globally.
2888        // Key: (file_path, fixture_name) -> usage_count
2889        let mut definition_usage_counts: HashMap<(PathBuf, String), usize> = HashMap::new();
2890
2891        for entry in self.definitions.iter() {
2892            let fixture_name = entry.key();
2893            for def in entry.value().iter() {
2894                let refs = self.find_references_for_definition(def);
2895                definition_usage_counts
2896                    .insert((def.file_path.clone(), fixture_name.clone()), refs.len());
2897            }
2898        }
2899
2900        // Build a tree structure from paths
2901        let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
2902        let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
2903
2904        for file_path in file_fixtures.keys() {
2905            all_paths.insert(file_path.clone());
2906
2907            // Add all parent directories
2908            let mut current = file_path.as_path();
2909            while let Some(parent) = current.parent() {
2910                if parent == root_path || parent.as_os_str().is_empty() {
2911                    break;
2912                }
2913                all_paths.insert(parent.to_path_buf());
2914                current = parent;
2915            }
2916        }
2917
2918        // Build parent-child relationships
2919        for path in &all_paths {
2920            if let Some(parent) = path.parent() {
2921                if parent != root_path && !parent.as_os_str().is_empty() {
2922                    tree.entry(parent.to_path_buf())
2923                        .or_default()
2924                        .push(path.clone());
2925                }
2926            }
2927        }
2928
2929        // Sort children in each directory
2930        for children in tree.values_mut() {
2931            children.sort();
2932        }
2933
2934        // Print the tree
2935        println!("Fixtures tree for: {}", root_path.display());
2936        println!();
2937
2938        if file_fixtures.is_empty() {
2939            println!("No fixtures found in this directory.");
2940            return;
2941        }
2942
2943        // Find top-level items (direct children of root)
2944        let mut top_level: Vec<PathBuf> = all_paths
2945            .iter()
2946            .filter(|p| {
2947                if let Some(parent) = p.parent() {
2948                    parent == root_path
2949                } else {
2950                    false
2951                }
2952            })
2953            .cloned()
2954            .collect();
2955        top_level.sort();
2956
2957        for (i, path) in top_level.iter().enumerate() {
2958            let is_last = i == top_level.len() - 1;
2959            self.print_tree_node(
2960                path,
2961                &file_fixtures,
2962                &tree,
2963                "",
2964                is_last,
2965                true, // is_root_level
2966                &definition_usage_counts,
2967                skip_unused,
2968                only_unused,
2969            );
2970        }
2971    }
2972
2973    #[allow(clippy::too_many_arguments)]
2974    #[allow(clippy::only_used_in_recursion)]
2975    fn print_tree_node(
2976        &self,
2977        path: &Path,
2978        file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2979        tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2980        prefix: &str,
2981        is_last: bool,
2982        is_root_level: bool,
2983        definition_usage_counts: &HashMap<(PathBuf, String), usize>,
2984        skip_unused: bool,
2985        only_unused: bool,
2986    ) {
2987        use colored::Colorize;
2988        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
2989
2990        // Print current node
2991        let connector = if is_root_level {
2992            "" // No connector for root level
2993        } else if is_last {
2994            "└── "
2995        } else {
2996            "├── "
2997        };
2998
2999        if path.is_file() {
3000            // Print file with fixtures
3001            if let Some(fixtures) = file_fixtures.get(path) {
3002                // Filter fixtures based on flags
3003                let fixture_vec: Vec<_> = fixtures
3004                    .iter()
3005                    .filter(|fixture_name| {
3006                        // Look up usage count using (file_path, fixture_name) key
3007                        let usage_count = definition_usage_counts
3008                            .get(&(path.to_path_buf(), (*fixture_name).clone()))
3009                            .copied()
3010                            .unwrap_or(0);
3011                        if only_unused {
3012                            usage_count == 0
3013                        } else if skip_unused {
3014                            usage_count > 0
3015                        } else {
3016                            true
3017                        }
3018                    })
3019                    .collect();
3020
3021                // Skip this file if no fixtures match the filter
3022                if fixture_vec.is_empty() {
3023                    return;
3024                }
3025
3026                let file_display = name.to_string().cyan().bold();
3027                println!(
3028                    "{}{}{} ({} fixtures)",
3029                    prefix,
3030                    connector,
3031                    file_display,
3032                    fixture_vec.len()
3033                );
3034
3035                // Print fixtures in this file
3036                let new_prefix = if is_root_level {
3037                    "".to_string()
3038                } else {
3039                    format!("{}{}", prefix, if is_last { "    " } else { "│   " })
3040                };
3041
3042                for (j, fixture_name) in fixture_vec.iter().enumerate() {
3043                    let is_last_fixture = j == fixture_vec.len() - 1;
3044                    let fixture_connector = if is_last_fixture {
3045                        "└── "
3046                    } else {
3047                        "├── "
3048                    };
3049
3050                    // Get usage count for this specific definition (file_path, fixture_name)
3051                    let usage_count = definition_usage_counts
3052                        .get(&(path.to_path_buf(), (*fixture_name).clone()))
3053                        .copied()
3054                        .unwrap_or(0);
3055
3056                    // Format the fixture name with color based on usage
3057                    let fixture_display = if usage_count == 0 {
3058                        // Unused fixture - show in dim/gray
3059                        fixture_name.to_string().dimmed()
3060                    } else {
3061                        // Used fixture - show in green
3062                        fixture_name.to_string().green()
3063                    };
3064
3065                    // Format usage count
3066                    let usage_info = if usage_count == 0 {
3067                        "unused".dimmed().to_string()
3068                    } else if usage_count == 1 {
3069                        format!("{}", "used 1 time".yellow())
3070                    } else {
3071                        format!("{}", format!("used {} times", usage_count).yellow())
3072                    };
3073
3074                    println!(
3075                        "{}{}{} ({})",
3076                        new_prefix, fixture_connector, fixture_display, usage_info
3077                    );
3078                }
3079            } else {
3080                println!("{}{}{}", prefix, connector, name);
3081            }
3082        } else {
3083            // Print directory - but first check if it has any visible children
3084            if let Some(children) = tree.get(path) {
3085                // Check if any children will be visible
3086                let has_visible_children = children.iter().any(|child| {
3087                    Self::has_visible_fixtures(
3088                        child,
3089                        file_fixtures,
3090                        tree,
3091                        definition_usage_counts,
3092                        skip_unused,
3093                        only_unused,
3094                    )
3095                });
3096
3097                if !has_visible_children {
3098                    return;
3099                }
3100
3101                let dir_display = format!("{}/", name).blue().bold();
3102                println!("{}{}{}", prefix, connector, dir_display);
3103
3104                let new_prefix = if is_root_level {
3105                    "".to_string()
3106                } else {
3107                    format!("{}{}", prefix, if is_last { "    " } else { "│   " })
3108                };
3109
3110                for (j, child) in children.iter().enumerate() {
3111                    let is_last_child = j == children.len() - 1;
3112                    self.print_tree_node(
3113                        child,
3114                        file_fixtures,
3115                        tree,
3116                        &new_prefix,
3117                        is_last_child,
3118                        false, // is_root_level
3119                        definition_usage_counts,
3120                        skip_unused,
3121                        only_unused,
3122                    );
3123                }
3124            }
3125        }
3126    }
3127
3128    fn has_visible_fixtures(
3129        path: &Path,
3130        file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
3131        tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
3132        definition_usage_counts: &HashMap<(PathBuf, String), usize>,
3133        skip_unused: bool,
3134        only_unused: bool,
3135    ) -> bool {
3136        if path.is_file() {
3137            // Check if this file has any fixtures matching the filter
3138            if let Some(fixtures) = file_fixtures.get(path) {
3139                return fixtures.iter().any(|fixture_name| {
3140                    let usage_count = definition_usage_counts
3141                        .get(&(path.to_path_buf(), fixture_name.clone()))
3142                        .copied()
3143                        .unwrap_or(0);
3144                    if only_unused {
3145                        usage_count == 0
3146                    } else if skip_unused {
3147                        usage_count > 0
3148                    } else {
3149                        true
3150                    }
3151                });
3152            }
3153            false
3154        } else {
3155            // Check if any children have visible fixtures
3156            if let Some(children) = tree.get(path) {
3157                children.iter().any(|child| {
3158                    Self::has_visible_fixtures(
3159                        child,
3160                        file_fixtures,
3161                        tree,
3162                        definition_usage_counts,
3163                        skip_unused,
3164                        only_unused,
3165                    )
3166                })
3167            } else {
3168                false
3169            }
3170        }
3171    }
3172}