pytest_language_server/
fixtures.rs

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