pytest_language_server/
fixtures.rs

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