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}
16
17#[derive(Debug, Clone)]
18pub struct FixtureUsage {
19    pub name: String,
20    pub file_path: PathBuf,
21    pub line: usize,
22    pub start_char: usize, // Character position where this usage starts (on the line)
23    pub end_char: usize,   // Character position where this usage ends (on the line)
24}
25
26#[derive(Debug, Clone)]
27pub struct UndeclaredFixture {
28    pub name: String,
29    pub file_path: PathBuf,
30    pub line: usize,
31    pub start_char: usize,
32    pub end_char: usize,
33    pub function_name: String, // Name of the test/fixture function where this is used
34    pub function_line: usize,  // Line where the function is defined
35}
36
37#[derive(Debug)]
38pub struct FixtureDatabase {
39    // Map from fixture name to all its definitions (can be in multiple conftest.py files)
40    definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
41    // Map from file path to fixtures used in that file
42    usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
43    // Cache of file contents for analyzed files (uses Arc for efficient sharing)
44    file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
45    // Map from file path to undeclared fixtures used in function bodies
46    undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
47    // Map from file path to imported names in that file
48    imports: Arc<DashMap<PathBuf, std::collections::HashSet<String>>>,
49}
50
51impl Default for FixtureDatabase {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl FixtureDatabase {
58    pub fn new() -> Self {
59        Self {
60            definitions: Arc::new(DashMap::new()),
61            usages: Arc::new(DashMap::new()),
62            file_cache: Arc::new(DashMap::new()),
63            undeclared_fixtures: Arc::new(DashMap::new()),
64            imports: Arc::new(DashMap::new()),
65        }
66    }
67
68    /// Scan a workspace directory for test files and conftest.py files
69    pub fn scan_workspace(&self, root_path: &Path) {
70        info!("Scanning workspace: {:?}", root_path);
71        let mut file_count = 0;
72
73        for entry in WalkDir::new(root_path).into_iter().filter_map(|e| e.ok()) {
74            let path = entry.path();
75
76            // Look for conftest.py or test_*.py or *_test.py files
77            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
78                if filename == "conftest.py"
79                    || filename.starts_with("test_") && filename.ends_with(".py")
80                    || filename.ends_with("_test.py")
81                {
82                    debug!("Found test/conftest file: {:?}", path);
83                    if let Ok(content) = std::fs::read_to_string(path) {
84                        self.analyze_file(path.to_path_buf(), &content);
85                        file_count += 1;
86                    }
87                }
88            }
89        }
90
91        info!("Workspace scan complete. Processed {} files", file_count);
92
93        // Also scan virtual environment for pytest plugins
94        self.scan_venv_fixtures(root_path);
95
96        info!("Total fixtures defined: {}", self.definitions.len());
97        info!("Total files with fixture usages: {}", self.usages.len());
98    }
99
100    /// Scan virtual environment for pytest plugin fixtures
101    fn scan_venv_fixtures(&self, root_path: &Path) {
102        info!("Scanning for pytest plugins in virtual environment");
103
104        // Try to find virtual environment
105        let venv_paths = vec![
106            root_path.join(".venv"),
107            root_path.join("venv"),
108            root_path.join("env"),
109        ];
110
111        info!("Checking for venv in: {:?}", root_path);
112        for venv_path in &venv_paths {
113            debug!("Checking venv path: {:?}", venv_path);
114            if venv_path.exists() {
115                info!("Found virtual environment at: {:?}", venv_path);
116                self.scan_venv_site_packages(venv_path);
117                return;
118            } else {
119                debug!("  Does not exist: {:?}", venv_path);
120            }
121        }
122
123        // Also check for system-wide VIRTUAL_ENV
124        if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
125            info!("Found VIRTUAL_ENV environment variable: {}", venv);
126            let venv_path = PathBuf::from(venv);
127            if venv_path.exists() {
128                info!("Using VIRTUAL_ENV: {:?}", venv_path);
129                self.scan_venv_site_packages(&venv_path);
130                return;
131            } else {
132                warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
133            }
134        } else {
135            debug!("No VIRTUAL_ENV environment variable set");
136        }
137
138        warn!("No virtual environment found - third-party fixtures will not be available");
139    }
140
141    fn scan_venv_site_packages(&self, venv_path: &Path) {
142        info!("Scanning venv site-packages in: {:?}", venv_path);
143
144        // Find site-packages directory
145        let lib_path = venv_path.join("lib");
146        debug!("Checking lib path: {:?}", lib_path);
147
148        if lib_path.exists() {
149            // Look for python* directories
150            if let Ok(entries) = std::fs::read_dir(&lib_path) {
151                for entry in entries.flatten() {
152                    let path = entry.path();
153                    let dirname = path.file_name().unwrap_or_default().to_string_lossy();
154                    debug!("Found in lib: {:?}", dirname);
155
156                    if path.is_dir() && dirname.starts_with("python") {
157                        let site_packages = path.join("site-packages");
158                        debug!("Checking site-packages: {:?}", site_packages);
159
160                        if site_packages.exists() {
161                            info!("Found site-packages: {:?}", site_packages);
162                            self.scan_pytest_plugins(&site_packages);
163                            return;
164                        }
165                    }
166                }
167            }
168        }
169
170        // Try Windows path
171        let windows_site_packages = venv_path.join("Lib/site-packages");
172        debug!("Checking Windows path: {:?}", windows_site_packages);
173        if windows_site_packages.exists() {
174            info!("Found site-packages (Windows): {:?}", windows_site_packages);
175            self.scan_pytest_plugins(&windows_site_packages);
176            return;
177        }
178
179        warn!("Could not find site-packages in venv: {:?}", venv_path);
180    }
181
182    fn scan_pytest_plugins(&self, site_packages: &Path) {
183        info!("Scanning pytest plugins in: {:?}", site_packages);
184
185        // List of known pytest plugin prefixes/packages
186        let pytest_packages = vec![
187            "pytest_mock",
188            "pytest-mock",
189            "pytest_asyncio",
190            "pytest-asyncio",
191            "pytest_django",
192            "pytest-django",
193            "pytest_cov",
194            "pytest-cov",
195            "pytest_xdist",
196            "pytest-xdist",
197            "pytest_fixtures",
198        ];
199
200        let mut plugin_count = 0;
201
202        for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
203            let entry = match entry {
204                Ok(e) => e,
205                Err(_) => continue,
206            };
207
208            let path = entry.path();
209            let filename = path.file_name().unwrap_or_default().to_string_lossy();
210
211            // Check if this is a pytest-related package
212            let is_pytest_package = pytest_packages.iter().any(|pkg| filename.contains(pkg))
213                || filename.starts_with("pytest")
214                || filename.contains("_pytest");
215
216            if is_pytest_package && path.is_dir() {
217                // Skip .dist-info directories - they don't contain code
218                if filename.ends_with(".dist-info") || filename.ends_with(".egg-info") {
219                    debug!("Skipping dist-info directory: {:?}", filename);
220                    continue;
221                }
222
223                info!("Scanning pytest plugin: {:?}", path);
224                plugin_count += 1;
225                self.scan_plugin_directory(&path);
226            } else {
227                // Log packages we're skipping for debugging
228                if filename.contains("mock") {
229                    debug!("Found mock-related package (not scanning): {:?}", filename);
230                }
231            }
232        }
233
234        info!("Scanned {} pytest plugin packages", plugin_count);
235    }
236
237    fn scan_plugin_directory(&self, plugin_dir: &Path) {
238        // Recursively scan for Python files with fixtures
239        for entry in WalkDir::new(plugin_dir)
240            .max_depth(3) // Limit depth to avoid scanning too much
241            .into_iter()
242            .filter_map(|e| e.ok())
243        {
244            let path = entry.path();
245
246            if path.extension().and_then(|s| s.to_str()) == Some("py") {
247                // Only scan files that might have fixtures (not test files)
248                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
249                    // Skip test files and __pycache__
250                    if filename.starts_with("test_") || filename.contains("__pycache__") {
251                        continue;
252                    }
253
254                    debug!("Scanning plugin file: {:?}", path);
255                    if let Ok(content) = std::fs::read_to_string(path) {
256                        self.analyze_file(path.to_path_buf(), &content);
257                    }
258                }
259            }
260        }
261    }
262
263    /// Analyze a single Python file for fixtures using AST parsing
264    pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
265        // Canonicalize the path to handle symlinks and normalize path representation
266        // This ensures consistent path comparisons later
267        let file_path = file_path.canonicalize().unwrap_or_else(|_| {
268            // If canonicalization fails (e.g., file doesn't exist yet, or on some filesystems),
269            // fall back to the original path
270            debug!(
271                "Warning: Could not canonicalize path {:?}, using as-is",
272                file_path
273            );
274            file_path
275        });
276
277        debug!("Analyzing file: {:?}", file_path);
278
279        // Cache the file content for later use (e.g., in find_fixture_definition)
280        // Use Arc for efficient sharing without cloning
281        self.file_cache
282            .insert(file_path.clone(), Arc::new(content.to_string()));
283
284        // Parse the Python code
285        let parsed = match parse(content, Mode::Module, "") {
286            Ok(ast) => ast,
287            Err(e) => {
288                warn!("Failed to parse {:?}: {:?}", file_path, e);
289                return;
290            }
291        };
292
293        // Clear previous usages for this file
294        self.usages.remove(&file_path);
295
296        // Clear previous undeclared fixtures for this file
297        self.undeclared_fixtures.remove(&file_path);
298
299        // Clear previous imports for this file
300        self.imports.remove(&file_path);
301
302        // Clear previous fixture definitions from this file
303        // We need to remove definitions that were in this file
304        for mut entry in self.definitions.iter_mut() {
305            entry.value_mut().retain(|def| def.file_path != file_path);
306        }
307        // Remove empty entries
308        self.definitions.retain(|_, defs| !defs.is_empty());
309
310        // Check if this is a conftest.py
311        let is_conftest = file_path
312            .file_name()
313            .map(|n| n == "conftest.py")
314            .unwrap_or(false);
315        debug!("is_conftest: {}", is_conftest);
316
317        // Process each statement in the module
318        if let rustpython_parser::ast::Mod::Module(module) = parsed {
319            debug!("Module has {} statements", module.body.len());
320
321            // First pass: collect all module-level names (imports, assignments, function/class defs)
322            let mut module_level_names = std::collections::HashSet::new();
323            for stmt in &module.body {
324                self.collect_module_level_names(stmt, &mut module_level_names);
325            }
326            self.imports.insert(file_path.clone(), module_level_names);
327
328            // Second pass: analyze fixtures and tests
329            for stmt in &module.body {
330                self.visit_stmt(stmt, &file_path, is_conftest, content);
331            }
332        }
333
334        debug!("Analysis complete for {:?}", file_path);
335    }
336
337    fn visit_stmt(&self, stmt: &Stmt, file_path: &PathBuf, _is_conftest: bool, content: &str) {
338        // First check for assignment-style fixtures: fixture_name = pytest.fixture()(func)
339        if let Stmt::Assign(assign) = stmt {
340            self.visit_assignment_fixture(assign, file_path, content);
341        }
342
343        // Handle both regular and async function definitions
344        let (func_name, decorator_list, args, range, body) = match stmt {
345            Stmt::FunctionDef(func_def) => (
346                func_def.name.as_str(),
347                &func_def.decorator_list,
348                &func_def.args,
349                func_def.range,
350                &func_def.body,
351            ),
352            Stmt::AsyncFunctionDef(func_def) => (
353                func_def.name.as_str(),
354                &func_def.decorator_list,
355                &func_def.args,
356                func_def.range,
357                &func_def.body,
358            ),
359            _ => return,
360        };
361
362        debug!("Found function: {}", func_name);
363
364        // Check if this is a fixture definition
365        debug!(
366            "Function {} has {} decorators",
367            func_name,
368            decorator_list.len()
369        );
370        let is_fixture = decorator_list.iter().any(|dec| {
371            let result = Self::is_fixture_decorator(dec);
372            if result {
373                debug!("  Decorator matched as fixture!");
374            }
375            result
376        });
377
378        if is_fixture {
379            // Calculate line number from the range start
380            let line = self.get_line_from_offset(range.start().to_usize(), content);
381
382            // Extract docstring if present
383            let docstring = self.extract_docstring(body);
384
385            info!(
386                "Found fixture definition: {} at {:?}:{}",
387                func_name, file_path, line
388            );
389            if let Some(ref doc) = docstring {
390                debug!("  Docstring: {}", doc);
391            }
392
393            let definition = FixtureDefinition {
394                name: func_name.to_string(),
395                file_path: file_path.clone(),
396                line,
397                docstring,
398            };
399
400            self.definitions
401                .entry(func_name.to_string())
402                .or_default()
403                .push(definition);
404
405            // Fixtures can depend on other fixtures - record these as usages too
406            let mut declared_params: std::collections::HashSet<String> =
407                std::collections::HashSet::new();
408            declared_params.insert("self".to_string());
409            declared_params.insert("request".to_string());
410            declared_params.insert(func_name.to_string()); // Exclude function name itself
411
412            for arg in &args.args {
413                let arg_name = arg.def.arg.as_str();
414                declared_params.insert(arg_name.to_string());
415
416                if arg_name != "self" && arg_name != "request" {
417                    // Get the actual line where this parameter appears
418                    // arg.def.range contains the location of the parameter name
419                    let arg_line =
420                        self.get_line_from_offset(arg.def.range.start().to_usize(), content);
421                    let start_char = self
422                        .get_char_position_from_offset(arg.def.range.start().to_usize(), content);
423                    let end_char =
424                        self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
425
426                    info!(
427                        "Found fixture dependency: {} at {:?}:{}:{}",
428                        arg_name, file_path, arg_line, start_char
429                    );
430
431                    let usage = FixtureUsage {
432                        name: arg_name.to_string(),
433                        file_path: file_path.clone(),
434                        line: arg_line, // Use actual parameter line
435                        start_char,
436                        end_char,
437                    };
438
439                    self.usages
440                        .entry(file_path.clone())
441                        .or_default()
442                        .push(usage);
443                }
444            }
445
446            // Scan fixture body for undeclared fixture usages
447            let function_line = self.get_line_from_offset(range.start().to_usize(), content);
448            self.scan_function_body_for_undeclared_fixtures(
449                body,
450                file_path,
451                content,
452                &declared_params,
453                func_name,
454                function_line,
455            );
456        }
457
458        // Check if this is a test function
459        let is_test = func_name.starts_with("test_");
460
461        if is_test {
462            debug!("Found test function: {}", func_name);
463
464            // Collect declared parameters
465            let mut declared_params: std::collections::HashSet<String> =
466                std::collections::HashSet::new();
467            declared_params.insert("self".to_string());
468            declared_params.insert("request".to_string()); // pytest built-in
469
470            // Extract fixture usages from function parameters
471            for arg in &args.args {
472                let arg_name = arg.def.arg.as_str();
473                declared_params.insert(arg_name.to_string());
474
475                if arg_name != "self" {
476                    // Get the actual line where this parameter appears
477                    // This handles multiline function signatures correctly
478                    // arg.def.range contains the location of the parameter name
479                    let arg_offset = arg.def.range.start().to_usize();
480                    let arg_line = self.get_line_from_offset(arg_offset, content);
481                    let start_char = self.get_char_position_from_offset(arg_offset, content);
482                    let end_char =
483                        self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
484
485                    debug!(
486                        "Parameter {} at offset {}, calculated line {}, char {}",
487                        arg_name, arg_offset, arg_line, start_char
488                    );
489                    info!(
490                        "Found fixture usage: {} at {:?}:{}:{}",
491                        arg_name, file_path, arg_line, start_char
492                    );
493
494                    let usage = FixtureUsage {
495                        name: arg_name.to_string(),
496                        file_path: file_path.clone(),
497                        line: arg_line, // Use actual parameter line
498                        start_char,
499                        end_char,
500                    };
501
502                    // Append to existing usages for this file
503                    self.usages
504                        .entry(file_path.clone())
505                        .or_default()
506                        .push(usage);
507                }
508            }
509
510            // Now scan the function body for undeclared fixture usages
511            let function_line = self.get_line_from_offset(range.start().to_usize(), content);
512            self.scan_function_body_for_undeclared_fixtures(
513                body,
514                file_path,
515                content,
516                &declared_params,
517                func_name,
518                function_line,
519            );
520        }
521    }
522
523    fn visit_assignment_fixture(
524        &self,
525        assign: &rustpython_parser::ast::StmtAssign,
526        file_path: &PathBuf,
527        content: &str,
528    ) {
529        // Check for pattern: fixture_name = pytest.fixture()(func)
530        // The value should be a Call expression where the func is a Call to pytest.fixture()
531
532        if let Expr::Call(outer_call) = &*assign.value {
533            // Check if outer_call.func is pytest.fixture() or fixture()
534            if let Expr::Call(inner_call) = &*outer_call.func {
535                if Self::is_fixture_decorator(&inner_call.func) {
536                    // This is pytest.fixture()(something)
537                    // Get the fixture name from the assignment target
538                    for target in &assign.targets {
539                        if let Expr::Name(name) = target {
540                            let fixture_name = name.id.as_str();
541                            let line =
542                                self.get_line_from_offset(assign.range.start().to_usize(), content);
543
544                            info!(
545                                "Found fixture assignment: {} at {:?}:{}",
546                                fixture_name, file_path, line
547                            );
548
549                            // We don't have a docstring for assignment-style fixtures
550                            let definition = FixtureDefinition {
551                                name: fixture_name.to_string(),
552                                file_path: file_path.clone(),
553                                line,
554                                docstring: None,
555                            };
556
557                            self.definitions
558                                .entry(fixture_name.to_string())
559                                .or_default()
560                                .push(definition);
561                        }
562                    }
563                }
564            }
565        }
566    }
567
568    fn is_fixture_decorator(expr: &Expr) -> bool {
569        match expr {
570            Expr::Name(name) => name.id.as_str() == "fixture",
571            Expr::Attribute(attr) => {
572                // Check for pytest.fixture
573                if let Expr::Name(value) = &*attr.value {
574                    value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
575                } else {
576                    false
577                }
578            }
579            Expr::Call(call) => {
580                // Handle @pytest.fixture() or @fixture() with parentheses
581                Self::is_fixture_decorator(&call.func)
582            }
583            _ => false,
584        }
585    }
586
587    fn scan_function_body_for_undeclared_fixtures(
588        &self,
589        body: &[Stmt],
590        file_path: &PathBuf,
591        content: &str,
592        declared_params: &std::collections::HashSet<String>,
593        function_name: &str,
594        function_line: usize,
595    ) {
596        // First, collect all local variable names with their definition line numbers
597        let mut local_vars = std::collections::HashMap::new();
598        self.collect_local_variables(body, content, &mut local_vars);
599
600        // Also add imported names to local_vars (they shouldn't be flagged as undeclared fixtures)
601        // Set their line to 0 so they're always considered "in scope"
602        if let Some(imports) = self.imports.get(file_path) {
603            for import in imports.iter() {
604                local_vars.insert(import.clone(), 0);
605            }
606        }
607
608        // Walk through the function body and find all Name references
609        for stmt in body {
610            self.visit_stmt_for_names(
611                stmt,
612                file_path,
613                content,
614                declared_params,
615                &local_vars,
616                function_name,
617                function_line,
618            );
619        }
620    }
621
622    fn collect_module_level_names(
623        &self,
624        stmt: &Stmt,
625        names: &mut std::collections::HashSet<String>,
626    ) {
627        match stmt {
628            // Imports
629            Stmt::Import(import_stmt) => {
630                for alias in &import_stmt.names {
631                    // If there's an "as" alias, use that; otherwise use the original name
632                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
633                    names.insert(name.to_string());
634                }
635            }
636            Stmt::ImportFrom(import_from) => {
637                for alias in &import_from.names {
638                    // If there's an "as" alias, use that; otherwise use the original name
639                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
640                    names.insert(name.to_string());
641                }
642            }
643            // Regular function definitions (not fixtures)
644            Stmt::FunctionDef(func_def) => {
645                // Check if this is NOT a fixture
646                let is_fixture = func_def
647                    .decorator_list
648                    .iter()
649                    .any(Self::is_fixture_decorator);
650                if !is_fixture {
651                    names.insert(func_def.name.to_string());
652                }
653            }
654            // Async function definitions (not fixtures)
655            Stmt::AsyncFunctionDef(func_def) => {
656                let is_fixture = func_def
657                    .decorator_list
658                    .iter()
659                    .any(Self::is_fixture_decorator);
660                if !is_fixture {
661                    names.insert(func_def.name.to_string());
662                }
663            }
664            // Class definitions
665            Stmt::ClassDef(class_def) => {
666                names.insert(class_def.name.to_string());
667            }
668            // Module-level assignments
669            Stmt::Assign(assign) => {
670                for target in &assign.targets {
671                    self.collect_names_from_expr(target, names);
672                }
673            }
674            Stmt::AnnAssign(ann_assign) => {
675                self.collect_names_from_expr(&ann_assign.target, names);
676            }
677            _ => {}
678        }
679    }
680
681    fn collect_local_variables(
682        &self,
683        body: &[Stmt],
684        content: &str,
685        local_vars: &mut std::collections::HashMap<String, usize>,
686    ) {
687        for stmt in body {
688            match stmt {
689                Stmt::Assign(assign) => {
690                    // Collect variable names from left-hand side with their line numbers
691                    let line = self.get_line_from_offset(assign.range.start().to_usize(), content);
692                    let mut temp_names = std::collections::HashSet::new();
693                    for target in &assign.targets {
694                        self.collect_names_from_expr(target, &mut temp_names);
695                    }
696                    for name in temp_names {
697                        local_vars.insert(name, line);
698                    }
699                }
700                Stmt::AnnAssign(ann_assign) => {
701                    // Collect annotated assignment targets with their line numbers
702                    let line =
703                        self.get_line_from_offset(ann_assign.range.start().to_usize(), content);
704                    let mut temp_names = std::collections::HashSet::new();
705                    self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
706                    for name in temp_names {
707                        local_vars.insert(name, line);
708                    }
709                }
710                Stmt::AugAssign(aug_assign) => {
711                    // Collect augmented assignment targets (+=, -=, etc.)
712                    let line =
713                        self.get_line_from_offset(aug_assign.range.start().to_usize(), content);
714                    let mut temp_names = std::collections::HashSet::new();
715                    self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
716                    for name in temp_names {
717                        local_vars.insert(name, line);
718                    }
719                }
720                Stmt::For(for_stmt) => {
721                    // Collect loop variable with its line number
722                    let line =
723                        self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
724                    let mut temp_names = std::collections::HashSet::new();
725                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
726                    for name in temp_names {
727                        local_vars.insert(name, line);
728                    }
729                    // Recursively collect from body
730                    self.collect_local_variables(&for_stmt.body, content, local_vars);
731                }
732                Stmt::AsyncFor(for_stmt) => {
733                    let line =
734                        self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
735                    let mut temp_names = std::collections::HashSet::new();
736                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
737                    for name in temp_names {
738                        local_vars.insert(name, line);
739                    }
740                    self.collect_local_variables(&for_stmt.body, content, local_vars);
741                }
742                Stmt::While(while_stmt) => {
743                    self.collect_local_variables(&while_stmt.body, content, local_vars);
744                }
745                Stmt::If(if_stmt) => {
746                    self.collect_local_variables(&if_stmt.body, content, local_vars);
747                    self.collect_local_variables(&if_stmt.orelse, content, local_vars);
748                }
749                Stmt::With(with_stmt) => {
750                    // Collect context manager variables with their line numbers
751                    let line =
752                        self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
753                    for item in &with_stmt.items {
754                        if let Some(ref optional_vars) = item.optional_vars {
755                            let mut temp_names = std::collections::HashSet::new();
756                            self.collect_names_from_expr(optional_vars, &mut temp_names);
757                            for name in temp_names {
758                                local_vars.insert(name, line);
759                            }
760                        }
761                    }
762                    self.collect_local_variables(&with_stmt.body, content, local_vars);
763                }
764                Stmt::AsyncWith(with_stmt) => {
765                    let line =
766                        self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
767                    for item in &with_stmt.items {
768                        if let Some(ref optional_vars) = item.optional_vars {
769                            let mut temp_names = std::collections::HashSet::new();
770                            self.collect_names_from_expr(optional_vars, &mut temp_names);
771                            for name in temp_names {
772                                local_vars.insert(name, line);
773                            }
774                        }
775                    }
776                    self.collect_local_variables(&with_stmt.body, content, local_vars);
777                }
778                Stmt::Try(try_stmt) => {
779                    self.collect_local_variables(&try_stmt.body, content, local_vars);
780                    // Note: ExceptHandler struct doesn't expose name/body in current API
781                    // This is a limitation of rustpython-parser 0.4.0
782                    self.collect_local_variables(&try_stmt.orelse, content, local_vars);
783                    self.collect_local_variables(&try_stmt.finalbody, content, local_vars);
784                }
785                _ => {}
786            }
787        }
788    }
789
790    #[allow(clippy::only_used_in_recursion)]
791    fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
792        match expr {
793            Expr::Name(name) => {
794                names.insert(name.id.to_string());
795            }
796            Expr::Tuple(tuple) => {
797                for elt in &tuple.elts {
798                    self.collect_names_from_expr(elt, names);
799                }
800            }
801            Expr::List(list) => {
802                for elt in &list.elts {
803                    self.collect_names_from_expr(elt, names);
804                }
805            }
806            _ => {}
807        }
808    }
809
810    #[allow(clippy::too_many_arguments)]
811    fn visit_stmt_for_names(
812        &self,
813        stmt: &Stmt,
814        file_path: &PathBuf,
815        content: &str,
816        declared_params: &std::collections::HashSet<String>,
817        local_vars: &std::collections::HashMap<String, usize>,
818        function_name: &str,
819        function_line: usize,
820    ) {
821        match stmt {
822            Stmt::Expr(expr_stmt) => {
823                self.visit_expr_for_names(
824                    &expr_stmt.value,
825                    file_path,
826                    content,
827                    declared_params,
828                    local_vars,
829                    function_name,
830                    function_line,
831                );
832            }
833            Stmt::Assign(assign) => {
834                self.visit_expr_for_names(
835                    &assign.value,
836                    file_path,
837                    content,
838                    declared_params,
839                    local_vars,
840                    function_name,
841                    function_line,
842                );
843            }
844            Stmt::AugAssign(aug_assign) => {
845                self.visit_expr_for_names(
846                    &aug_assign.value,
847                    file_path,
848                    content,
849                    declared_params,
850                    local_vars,
851                    function_name,
852                    function_line,
853                );
854            }
855            Stmt::Return(ret) => {
856                if let Some(ref value) = ret.value {
857                    self.visit_expr_for_names(
858                        value,
859                        file_path,
860                        content,
861                        declared_params,
862                        local_vars,
863                        function_name,
864                        function_line,
865                    );
866                }
867            }
868            Stmt::If(if_stmt) => {
869                self.visit_expr_for_names(
870                    &if_stmt.test,
871                    file_path,
872                    content,
873                    declared_params,
874                    local_vars,
875                    function_name,
876                    function_line,
877                );
878                for stmt in &if_stmt.body {
879                    self.visit_stmt_for_names(
880                        stmt,
881                        file_path,
882                        content,
883                        declared_params,
884                        local_vars,
885                        function_name,
886                        function_line,
887                    );
888                }
889                for stmt in &if_stmt.orelse {
890                    self.visit_stmt_for_names(
891                        stmt,
892                        file_path,
893                        content,
894                        declared_params,
895                        local_vars,
896                        function_name,
897                        function_line,
898                    );
899                }
900            }
901            Stmt::While(while_stmt) => {
902                self.visit_expr_for_names(
903                    &while_stmt.test,
904                    file_path,
905                    content,
906                    declared_params,
907                    local_vars,
908                    function_name,
909                    function_line,
910                );
911                for stmt in &while_stmt.body {
912                    self.visit_stmt_for_names(
913                        stmt,
914                        file_path,
915                        content,
916                        declared_params,
917                        local_vars,
918                        function_name,
919                        function_line,
920                    );
921                }
922            }
923            Stmt::For(for_stmt) => {
924                self.visit_expr_for_names(
925                    &for_stmt.iter,
926                    file_path,
927                    content,
928                    declared_params,
929                    local_vars,
930                    function_name,
931                    function_line,
932                );
933                for stmt in &for_stmt.body {
934                    self.visit_stmt_for_names(
935                        stmt,
936                        file_path,
937                        content,
938                        declared_params,
939                        local_vars,
940                        function_name,
941                        function_line,
942                    );
943                }
944            }
945            Stmt::With(with_stmt) => {
946                for item in &with_stmt.items {
947                    self.visit_expr_for_names(
948                        &item.context_expr,
949                        file_path,
950                        content,
951                        declared_params,
952                        local_vars,
953                        function_name,
954                        function_line,
955                    );
956                }
957                for stmt in &with_stmt.body {
958                    self.visit_stmt_for_names(
959                        stmt,
960                        file_path,
961                        content,
962                        declared_params,
963                        local_vars,
964                        function_name,
965                        function_line,
966                    );
967                }
968            }
969            Stmt::AsyncFor(for_stmt) => {
970                self.visit_expr_for_names(
971                    &for_stmt.iter,
972                    file_path,
973                    content,
974                    declared_params,
975                    local_vars,
976                    function_name,
977                    function_line,
978                );
979                for stmt in &for_stmt.body {
980                    self.visit_stmt_for_names(
981                        stmt,
982                        file_path,
983                        content,
984                        declared_params,
985                        local_vars,
986                        function_name,
987                        function_line,
988                    );
989                }
990            }
991            Stmt::AsyncWith(with_stmt) => {
992                for item in &with_stmt.items {
993                    self.visit_expr_for_names(
994                        &item.context_expr,
995                        file_path,
996                        content,
997                        declared_params,
998                        local_vars,
999                        function_name,
1000                        function_line,
1001                    );
1002                }
1003                for stmt in &with_stmt.body {
1004                    self.visit_stmt_for_names(
1005                        stmt,
1006                        file_path,
1007                        content,
1008                        declared_params,
1009                        local_vars,
1010                        function_name,
1011                        function_line,
1012                    );
1013                }
1014            }
1015            Stmt::Assert(assert_stmt) => {
1016                self.visit_expr_for_names(
1017                    &assert_stmt.test,
1018                    file_path,
1019                    content,
1020                    declared_params,
1021                    local_vars,
1022                    function_name,
1023                    function_line,
1024                );
1025                if let Some(ref msg) = assert_stmt.msg {
1026                    self.visit_expr_for_names(
1027                        msg,
1028                        file_path,
1029                        content,
1030                        declared_params,
1031                        local_vars,
1032                        function_name,
1033                        function_line,
1034                    );
1035                }
1036            }
1037            _ => {} // Other statement types
1038        }
1039    }
1040
1041    #[allow(clippy::too_many_arguments)]
1042    fn visit_expr_for_names(
1043        &self,
1044        expr: &Expr,
1045        file_path: &PathBuf,
1046        content: &str,
1047        declared_params: &std::collections::HashSet<String>,
1048        local_vars: &std::collections::HashMap<String, usize>,
1049        function_name: &str,
1050        function_line: usize,
1051    ) {
1052        match expr {
1053            Expr::Name(name) => {
1054                let name_str = name.id.as_str();
1055                let line = self.get_line_from_offset(name.range.start().to_usize(), content);
1056
1057                // Check if this name is a known fixture and not a declared parameter
1058                // For local variables, only exclude them if they're defined BEFORE the current line
1059                // (Python variables are only in scope after they're assigned)
1060                let is_local_var_in_scope = local_vars
1061                    .get(name_str)
1062                    .map(|def_line| *def_line < line)
1063                    .unwrap_or(false);
1064
1065                if !declared_params.contains(name_str)
1066                    && !is_local_var_in_scope
1067                    && self.is_available_fixture(file_path, name_str)
1068                {
1069                    let start_char =
1070                        self.get_char_position_from_offset(name.range.start().to_usize(), content);
1071                    let end_char =
1072                        self.get_char_position_from_offset(name.range.end().to_usize(), content);
1073
1074                    info!(
1075                        "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1076                        name_str, file_path, line, start_char, function_name
1077                    );
1078
1079                    let undeclared = UndeclaredFixture {
1080                        name: name_str.to_string(),
1081                        file_path: file_path.clone(),
1082                        line,
1083                        start_char,
1084                        end_char,
1085                        function_name: function_name.to_string(),
1086                        function_line,
1087                    };
1088
1089                    self.undeclared_fixtures
1090                        .entry(file_path.clone())
1091                        .or_default()
1092                        .push(undeclared);
1093                }
1094            }
1095            Expr::Call(call) => {
1096                self.visit_expr_for_names(
1097                    &call.func,
1098                    file_path,
1099                    content,
1100                    declared_params,
1101                    local_vars,
1102                    function_name,
1103                    function_line,
1104                );
1105                for arg in &call.args {
1106                    self.visit_expr_for_names(
1107                        arg,
1108                        file_path,
1109                        content,
1110                        declared_params,
1111                        local_vars,
1112                        function_name,
1113                        function_line,
1114                    );
1115                }
1116            }
1117            Expr::Attribute(attr) => {
1118                self.visit_expr_for_names(
1119                    &attr.value,
1120                    file_path,
1121                    content,
1122                    declared_params,
1123                    local_vars,
1124                    function_name,
1125                    function_line,
1126                );
1127            }
1128            Expr::BinOp(binop) => {
1129                self.visit_expr_for_names(
1130                    &binop.left,
1131                    file_path,
1132                    content,
1133                    declared_params,
1134                    local_vars,
1135                    function_name,
1136                    function_line,
1137                );
1138                self.visit_expr_for_names(
1139                    &binop.right,
1140                    file_path,
1141                    content,
1142                    declared_params,
1143                    local_vars,
1144                    function_name,
1145                    function_line,
1146                );
1147            }
1148            Expr::UnaryOp(unaryop) => {
1149                self.visit_expr_for_names(
1150                    &unaryop.operand,
1151                    file_path,
1152                    content,
1153                    declared_params,
1154                    local_vars,
1155                    function_name,
1156                    function_line,
1157                );
1158            }
1159            Expr::Compare(compare) => {
1160                self.visit_expr_for_names(
1161                    &compare.left,
1162                    file_path,
1163                    content,
1164                    declared_params,
1165                    local_vars,
1166                    function_name,
1167                    function_line,
1168                );
1169                for comparator in &compare.comparators {
1170                    self.visit_expr_for_names(
1171                        comparator,
1172                        file_path,
1173                        content,
1174                        declared_params,
1175                        local_vars,
1176                        function_name,
1177                        function_line,
1178                    );
1179                }
1180            }
1181            Expr::Subscript(subscript) => {
1182                self.visit_expr_for_names(
1183                    &subscript.value,
1184                    file_path,
1185                    content,
1186                    declared_params,
1187                    local_vars,
1188                    function_name,
1189                    function_line,
1190                );
1191                self.visit_expr_for_names(
1192                    &subscript.slice,
1193                    file_path,
1194                    content,
1195                    declared_params,
1196                    local_vars,
1197                    function_name,
1198                    function_line,
1199                );
1200            }
1201            Expr::List(list) => {
1202                for elt in &list.elts {
1203                    self.visit_expr_for_names(
1204                        elt,
1205                        file_path,
1206                        content,
1207                        declared_params,
1208                        local_vars,
1209                        function_name,
1210                        function_line,
1211                    );
1212                }
1213            }
1214            Expr::Tuple(tuple) => {
1215                for elt in &tuple.elts {
1216                    self.visit_expr_for_names(
1217                        elt,
1218                        file_path,
1219                        content,
1220                        declared_params,
1221                        local_vars,
1222                        function_name,
1223                        function_line,
1224                    );
1225                }
1226            }
1227            Expr::Dict(dict) => {
1228                for k in dict.keys.iter().flatten() {
1229                    self.visit_expr_for_names(
1230                        k,
1231                        file_path,
1232                        content,
1233                        declared_params,
1234                        local_vars,
1235                        function_name,
1236                        function_line,
1237                    );
1238                }
1239                for value in &dict.values {
1240                    self.visit_expr_for_names(
1241                        value,
1242                        file_path,
1243                        content,
1244                        declared_params,
1245                        local_vars,
1246                        function_name,
1247                        function_line,
1248                    );
1249                }
1250            }
1251            Expr::Await(await_expr) => {
1252                // Handle await expressions (async functions)
1253                self.visit_expr_for_names(
1254                    &await_expr.value,
1255                    file_path,
1256                    content,
1257                    declared_params,
1258                    local_vars,
1259                    function_name,
1260                    function_line,
1261                );
1262            }
1263            _ => {} // Other expression types
1264        }
1265    }
1266
1267    fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1268        // Check if this fixture exists and is available at this file location
1269        if let Some(definitions) = self.definitions.get(fixture_name) {
1270            // Check if any definition is available from this file location
1271            for def in definitions.iter() {
1272                // Fixture is available if it's in the same file or in a conftest.py in a parent directory
1273                if def.file_path == file_path {
1274                    return true;
1275                }
1276
1277                // Check if it's in a conftest.py in a parent directory
1278                if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1279                    && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1280                {
1281                    return true;
1282                }
1283
1284                // Check if it's in a virtual environment (third-party fixture)
1285                if def.file_path.to_string_lossy().contains("site-packages") {
1286                    return true;
1287                }
1288            }
1289        }
1290        false
1291    }
1292
1293    fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1294        // Python docstrings are the first statement in a function if it's an Expr containing a Constant string
1295        if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1296            if let Expr::Constant(constant) = &*expr_stmt.value {
1297                // Check if the constant is a string
1298                if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1299                    return Some(self.format_docstring(s.to_string()));
1300                }
1301            }
1302        }
1303        None
1304    }
1305
1306    fn format_docstring(&self, docstring: String) -> String {
1307        // Process docstring similar to Python's inspect.cleandoc()
1308        // 1. Split into lines
1309        let lines: Vec<&str> = docstring.lines().collect();
1310
1311        if lines.is_empty() {
1312            return String::new();
1313        }
1314
1315        // 2. Strip leading and trailing empty lines
1316        let mut start = 0;
1317        let mut end = lines.len();
1318
1319        while start < lines.len() && lines[start].trim().is_empty() {
1320            start += 1;
1321        }
1322
1323        while end > start && lines[end - 1].trim().is_empty() {
1324            end -= 1;
1325        }
1326
1327        if start >= end {
1328            return String::new();
1329        }
1330
1331        let lines = &lines[start..end];
1332
1333        // 3. Find minimum indentation (ignoring first line if it's not empty)
1334        let mut min_indent = usize::MAX;
1335        for (i, line) in lines.iter().enumerate() {
1336            if i == 0 && !line.trim().is_empty() {
1337                // First line might not be indented, skip it
1338                continue;
1339            }
1340
1341            if !line.trim().is_empty() {
1342                let indent = line.len() - line.trim_start().len();
1343                min_indent = min_indent.min(indent);
1344            }
1345        }
1346
1347        if min_indent == usize::MAX {
1348            min_indent = 0;
1349        }
1350
1351        // 4. Remove the common indentation from all lines (except possibly first)
1352        let mut result = Vec::new();
1353        for (i, line) in lines.iter().enumerate() {
1354            if i == 0 {
1355                // First line: just trim it
1356                result.push(line.trim().to_string());
1357            } else if line.trim().is_empty() {
1358                // Empty line: keep it empty
1359                result.push(String::new());
1360            } else {
1361                // Remove common indentation
1362                let dedented = if line.len() > min_indent {
1363                    &line[min_indent..]
1364                } else {
1365                    line.trim_start()
1366                };
1367                result.push(dedented.to_string());
1368            }
1369        }
1370
1371        // 5. Join lines back together
1372        result.join("\n")
1373    }
1374
1375    fn get_line_from_offset(&self, offset: usize, content: &str) -> usize {
1376        // Count newlines before this offset, then add 1 for 1-based line numbers
1377        content[..offset].matches('\n').count() + 1
1378    }
1379
1380    fn get_char_position_from_offset(&self, offset: usize, content: &str) -> usize {
1381        // Find the last newline before this offset
1382        if let Some(line_start) = content[..offset].rfind('\n') {
1383            // Character position is offset from start of line (after the newline)
1384            offset - line_start - 1
1385        } else {
1386            // No newline found, we're on the first line
1387            offset
1388        }
1389    }
1390
1391    /// Find fixture definition for a given position in a file
1392    pub fn find_fixture_definition(
1393        &self,
1394        file_path: &Path,
1395        line: u32,
1396        character: u32,
1397    ) -> Option<FixtureDefinition> {
1398        debug!(
1399            "find_fixture_definition: file={:?}, line={}, char={}",
1400            file_path, line, character
1401        );
1402
1403        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
1404
1405        // Read the file content - try cache first, then file system
1406        // Use Arc to avoid cloning large strings - just increments ref count
1407        let content: Arc<String> = if let Some(cached) = self.file_cache.get(file_path) {
1408            Arc::clone(cached.value())
1409        } else {
1410            Arc::new(std::fs::read_to_string(file_path).ok()?)
1411        };
1412
1413        // Avoid allocating Vec - access line directly via iterator
1414        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1415        debug!("Line content: {}", line_content);
1416
1417        // Extract the word at the character position
1418        let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1419        debug!("Word at cursor: {:?}", word_at_cursor);
1420
1421        // Check if we're inside a fixture definition with the same name (self-referencing)
1422        // In that case, we should skip the current definition and find the parent
1423        let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1424
1425        // First, check if this word matches any fixture usage on this line
1426        // AND that the cursor is within the character range of that usage
1427        if let Some(usages) = self.usages.get(file_path) {
1428            for usage in usages.iter() {
1429                if usage.line == target_line && usage.name == word_at_cursor {
1430                    // Check if cursor is within the character range of this usage
1431                    let cursor_pos = character as usize;
1432                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1433                        debug!(
1434                            "Cursor at {} is within usage range {}-{}: {}",
1435                            cursor_pos, usage.start_char, usage.end_char, usage.name
1436                        );
1437                        info!("Found fixture usage at cursor position: {}", usage.name);
1438
1439                        // If we're in a fixture definition with the same name, skip it when searching
1440                        if let Some(ref current_def) = current_fixture_def {
1441                            if current_def.name == word_at_cursor {
1442                                info!(
1443                                    "Self-referencing fixture detected, finding parent definition"
1444                                );
1445                                return self.find_closest_definition_excluding(
1446                                    file_path,
1447                                    &usage.name,
1448                                    Some(current_def),
1449                                );
1450                            }
1451                        }
1452
1453                        // Find the closest definition for this fixture
1454                        return self.find_closest_definition(file_path, &usage.name);
1455                    }
1456                }
1457            }
1458        }
1459
1460        debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1461        None
1462    }
1463
1464    /// Get the fixture definition at a specific line (if the line is a fixture definition)
1465    fn get_fixture_definition_at_line(
1466        &self,
1467        file_path: &Path,
1468        line: usize,
1469    ) -> Option<FixtureDefinition> {
1470        for entry in self.definitions.iter() {
1471            for def in entry.value().iter() {
1472                if def.file_path == file_path && def.line == line {
1473                    return Some(def.clone());
1474                }
1475            }
1476        }
1477        None
1478    }
1479
1480    /// Public method to get the fixture definition at a specific line and name
1481    /// Used when cursor is on a fixture definition line (not a usage)
1482    pub fn get_definition_at_line(
1483        &self,
1484        file_path: &Path,
1485        line: usize,
1486        fixture_name: &str,
1487    ) -> Option<FixtureDefinition> {
1488        if let Some(definitions) = self.definitions.get(fixture_name) {
1489            for def in definitions.iter() {
1490                if def.file_path == file_path && def.line == line {
1491                    return Some(def.clone());
1492                }
1493            }
1494        }
1495        None
1496    }
1497
1498    fn find_closest_definition(
1499        &self,
1500        file_path: &Path,
1501        fixture_name: &str,
1502    ) -> Option<FixtureDefinition> {
1503        let definitions = self.definitions.get(fixture_name)?;
1504
1505        // Priority 1: Check if fixture is defined in the same file (highest priority)
1506        // If multiple definitions exist in the same file, return the last one (pytest semantics)
1507        debug!(
1508            "Checking for fixture {} in same file: {:?}",
1509            fixture_name, file_path
1510        );
1511
1512        // Use iterator directly without collecting to Vec - more efficient
1513        if let Some(last_def) = definitions
1514            .iter()
1515            .filter(|def| def.file_path == file_path)
1516            .max_by_key(|def| def.line)
1517        {
1518            info!(
1519                "Found fixture {} in same file at line {} (using last definition)",
1520                fixture_name, last_def.line
1521            );
1522            return Some(last_def.clone());
1523        }
1524
1525        // Priority 2: Search upward through conftest.py files in parent directories
1526        // Start from the current file's directory and search upward
1527        let mut current_dir = file_path.parent()?;
1528
1529        debug!(
1530            "Searching for fixture {} in conftest.py files starting from {:?}",
1531            fixture_name, current_dir
1532        );
1533        loop {
1534            // Check for conftest.py in current directory
1535            let conftest_path = current_dir.join("conftest.py");
1536            debug!("  Checking conftest.py at: {:?}", conftest_path);
1537
1538            for def in definitions.iter() {
1539                if def.file_path == conftest_path {
1540                    info!(
1541                        "Found fixture {} in conftest.py: {:?}",
1542                        fixture_name, conftest_path
1543                    );
1544                    return Some(def.clone());
1545                }
1546            }
1547
1548            // Move up one directory
1549            match current_dir.parent() {
1550                Some(parent) => current_dir = parent,
1551                None => break,
1552            }
1553        }
1554
1555        // Priority 3: Check for third-party fixtures (from virtual environment)
1556        // These are fixtures from pytest plugins in site-packages
1557        debug!(
1558            "No fixture {} found in conftest hierarchy, checking for third-party fixtures",
1559            fixture_name
1560        );
1561        for def in definitions.iter() {
1562            if def.file_path.to_string_lossy().contains("site-packages") {
1563                info!(
1564                    "Found third-party fixture {} in site-packages: {:?}",
1565                    fixture_name, def.file_path
1566                );
1567                return Some(def.clone());
1568            }
1569        }
1570
1571        // Priority 4: If still no match, this means the fixture is defined somewhere
1572        // unrelated to the current file's hierarchy. This is unusual but can happen
1573        // when fixtures are defined in unrelated test directories.
1574        // Return the first definition sorted by path for determinism.
1575        warn!(
1576            "No fixture {} found following priority rules (same file, conftest hierarchy, third-party)",
1577            fixture_name
1578        );
1579        warn!(
1580            "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1581        );
1582
1583        let mut defs: Vec<_> = definitions.iter().cloned().collect();
1584        defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1585        defs.first().cloned()
1586    }
1587
1588    /// Find the closest definition for a fixture, excluding a specific definition
1589    /// This is useful for self-referencing fixtures where we need to find the parent definition
1590    fn find_closest_definition_excluding(
1591        &self,
1592        file_path: &Path,
1593        fixture_name: &str,
1594        exclude: Option<&FixtureDefinition>,
1595    ) -> Option<FixtureDefinition> {
1596        let definitions = self.definitions.get(fixture_name)?;
1597
1598        // Priority 1: Check if fixture is defined in the same file (highest priority)
1599        // but skip the excluded definition
1600        // If multiple definitions exist, use the last one (pytest semantics)
1601        debug!(
1602            "Checking for fixture {} in same file: {:?} (excluding: {:?})",
1603            fixture_name, file_path, exclude
1604        );
1605
1606        // Use iterator directly without collecting to Vec - more efficient
1607        if let Some(last_def) = definitions
1608            .iter()
1609            .filter(|def| {
1610                if def.file_path != file_path {
1611                    return false;
1612                }
1613                // Skip the excluded definition
1614                if let Some(excluded) = exclude {
1615                    if def == &excluded {
1616                        debug!("Skipping excluded definition at line {}", def.line);
1617                        return false;
1618                    }
1619                }
1620                true
1621            })
1622            .max_by_key(|def| def.line)
1623        {
1624            info!(
1625                "Found fixture {} in same file at line {} (using last definition, excluding specified)",
1626                fixture_name, last_def.line
1627            );
1628            return Some(last_def.clone());
1629        }
1630
1631        // Priority 2: Search upward through conftest.py files in parent directories
1632        let mut current_dir = file_path.parent()?;
1633
1634        debug!(
1635            "Searching for fixture {} in conftest.py files starting from {:?}",
1636            fixture_name, current_dir
1637        );
1638        loop {
1639            let conftest_path = current_dir.join("conftest.py");
1640            debug!("  Checking conftest.py at: {:?}", conftest_path);
1641
1642            for def in definitions.iter() {
1643                if def.file_path == conftest_path {
1644                    // Skip the excluded definition (though it's unlikely to be in a different file)
1645                    if let Some(excluded) = exclude {
1646                        if def == excluded {
1647                            debug!("Skipping excluded definition at line {}", def.line);
1648                            continue;
1649                        }
1650                    }
1651                    info!(
1652                        "Found fixture {} in conftest.py: {:?}",
1653                        fixture_name, conftest_path
1654                    );
1655                    return Some(def.clone());
1656                }
1657            }
1658
1659            // Move up one directory
1660            match current_dir.parent() {
1661                Some(parent) => current_dir = parent,
1662                None => break,
1663            }
1664        }
1665
1666        // Priority 3: Check for third-party fixtures (from virtual environment)
1667        debug!(
1668            "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
1669            fixture_name
1670        );
1671        for def in definitions.iter() {
1672            // Skip excluded definition
1673            if let Some(excluded) = exclude {
1674                if def == excluded {
1675                    continue;
1676                }
1677            }
1678            if def.file_path.to_string_lossy().contains("site-packages") {
1679                info!(
1680                    "Found third-party fixture {} in site-packages: {:?}",
1681                    fixture_name, def.file_path
1682                );
1683                return Some(def.clone());
1684            }
1685        }
1686
1687        // Priority 4: Deterministic fallback - return first definition by path (excluding specified)
1688        warn!(
1689            "No fixture {} found following priority rules (excluding specified)",
1690            fixture_name
1691        );
1692        warn!(
1693            "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1694        );
1695
1696        let mut defs: Vec<_> = definitions
1697            .iter()
1698            .filter(|def| {
1699                if let Some(excluded) = exclude {
1700                    def != &excluded
1701                } else {
1702                    true
1703                }
1704            })
1705            .cloned()
1706            .collect();
1707        defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1708        defs.first().cloned()
1709    }
1710
1711    /// Find the fixture name at a given position (either definition or usage)
1712    pub fn find_fixture_at_position(
1713        &self,
1714        file_path: &Path,
1715        line: u32,
1716        character: u32,
1717    ) -> Option<String> {
1718        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
1719
1720        debug!(
1721            "find_fixture_at_position: file={:?}, line={}, char={}",
1722            file_path, target_line, character
1723        );
1724
1725        // Read the file content - try cache first, then file system
1726        // Use Arc to avoid cloning large strings - just increments ref count
1727        let content: Arc<String> = if let Some(cached) = self.file_cache.get(file_path) {
1728            Arc::clone(cached.value())
1729        } else {
1730            Arc::new(std::fs::read_to_string(file_path).ok()?)
1731        };
1732
1733        // Avoid allocating Vec - access line directly via iterator
1734        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1735        debug!("Line content: {}", line_content);
1736
1737        // Extract the word at the character position
1738        let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
1739        debug!("Word at cursor: {:?}", word_at_cursor);
1740
1741        // Check if this word matches any fixture usage on this line
1742        // AND that the cursor is within the character range of that usage
1743        if let Some(usages) = self.usages.get(file_path) {
1744            for usage in usages.iter() {
1745                if usage.line == target_line {
1746                    // Check if cursor is within the character range of this usage
1747                    let cursor_pos = character as usize;
1748                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1749                        debug!(
1750                            "Cursor at {} is within usage range {}-{}: {}",
1751                            cursor_pos, usage.start_char, usage.end_char, usage.name
1752                        );
1753                        info!("Found fixture usage at cursor position: {}", usage.name);
1754                        return Some(usage.name.clone());
1755                    }
1756                }
1757            }
1758        }
1759
1760        // If no usage matched, check if we're on a fixture definition line
1761        // (but only if the cursor is NOT on a parameter name)
1762        for entry in self.definitions.iter() {
1763            for def in entry.value().iter() {
1764                if def.file_path == file_path && def.line == target_line {
1765                    // Check if the cursor is on the function name itself, not a parameter
1766                    if let Some(ref word) = word_at_cursor {
1767                        if word == &def.name {
1768                            info!(
1769                                "Found fixture definition name at cursor position: {}",
1770                                def.name
1771                            );
1772                            return Some(def.name.clone());
1773                        }
1774                    }
1775                    // If cursor is elsewhere on the definition line, don't return the fixture name
1776                    // unless it matches a parameter (which would be a usage)
1777                }
1778            }
1779        }
1780
1781        debug!("No fixture found at cursor position");
1782        None
1783    }
1784
1785    fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
1786        let chars: Vec<char> = line.chars().collect();
1787
1788        // If cursor is beyond the line, return None
1789        if character > chars.len() {
1790            return None;
1791        }
1792
1793        // Check if cursor is ON an identifier character
1794        if character < chars.len() {
1795            let c = chars[character];
1796            if c.is_alphanumeric() || c == '_' {
1797                // Cursor is ON an identifier character, extract the word
1798                let mut start = character;
1799                while start > 0 {
1800                    let prev_c = chars[start - 1];
1801                    if !prev_c.is_alphanumeric() && prev_c != '_' {
1802                        break;
1803                    }
1804                    start -= 1;
1805                }
1806
1807                let mut end = character;
1808                while end < chars.len() {
1809                    let curr_c = chars[end];
1810                    if !curr_c.is_alphanumeric() && curr_c != '_' {
1811                        break;
1812                    }
1813                    end += 1;
1814                }
1815
1816                if start < end {
1817                    return Some(chars[start..end].iter().collect());
1818                }
1819            }
1820        }
1821
1822        None
1823    }
1824
1825    /// Find all references (usages) of a fixture by name
1826    pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
1827        info!("Finding all references for fixture: {}", fixture_name);
1828
1829        let mut all_references = Vec::new();
1830
1831        // Iterate through all files that have usages
1832        for entry in self.usages.iter() {
1833            let file_path = entry.key();
1834            let usages = entry.value();
1835
1836            // Find all usages of this fixture in this file
1837            for usage in usages.iter() {
1838                if usage.name == fixture_name {
1839                    debug!(
1840                        "Found reference to {} in {:?} at line {}",
1841                        fixture_name, file_path, usage.line
1842                    );
1843                    all_references.push(usage.clone());
1844                }
1845            }
1846        }
1847
1848        info!(
1849            "Found {} total references for fixture: {}",
1850            all_references.len(),
1851            fixture_name
1852        );
1853        all_references
1854    }
1855
1856    /// Find all references (usages) that would resolve to a specific fixture definition
1857    /// This respects the priority rules: same file > closest conftest.py > parent conftest.py
1858    ///
1859    /// For fixture overriding, this handles self-referencing parameters correctly:
1860    /// If a fixture parameter appears on the same line as a fixture definition with the same name,
1861    /// we exclude that definition when resolving, so it finds the parent instead.
1862    pub fn find_references_for_definition(
1863        &self,
1864        definition: &FixtureDefinition,
1865    ) -> Vec<FixtureUsage> {
1866        info!(
1867            "Finding references for specific definition: {} at {:?}:{}",
1868            definition.name, definition.file_path, definition.line
1869        );
1870
1871        let mut matching_references = Vec::new();
1872
1873        // Get all usages of this fixture name
1874        for entry in self.usages.iter() {
1875            let file_path = entry.key();
1876            let usages = entry.value();
1877
1878            for usage in usages.iter() {
1879                if usage.name == definition.name {
1880                    // Check if this usage is on the same line as a fixture definition with the same name
1881                    // (i.e., a self-referencing fixture parameter like "def foo(foo):")
1882                    let fixture_def_at_line =
1883                        self.get_fixture_definition_at_line(file_path, usage.line);
1884
1885                    let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
1886                        if current_def.name == usage.name {
1887                            // Self-referencing parameter - exclude current definition and find parent
1888                            debug!(
1889                                "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
1890                                file_path, usage.line, current_def.line
1891                            );
1892                            self.find_closest_definition_excluding(
1893                                file_path,
1894                                &usage.name,
1895                                Some(current_def),
1896                            )
1897                        } else {
1898                            // Different fixture - use normal resolution
1899                            self.find_closest_definition(file_path, &usage.name)
1900                        }
1901                    } else {
1902                        // Not on a fixture definition line - use normal resolution
1903                        self.find_closest_definition(file_path, &usage.name)
1904                    };
1905
1906                    if let Some(resolved_def) = resolved_def {
1907                        if resolved_def == *definition {
1908                            debug!(
1909                                "Usage at {:?}:{} resolves to our definition",
1910                                file_path, usage.line
1911                            );
1912                            matching_references.push(usage.clone());
1913                        } else {
1914                            debug!(
1915                                "Usage at {:?}:{} resolves to different definition at {:?}:{}",
1916                                file_path, usage.line, resolved_def.file_path, resolved_def.line
1917                            );
1918                        }
1919                    }
1920                }
1921            }
1922        }
1923
1924        info!(
1925            "Found {} references that resolve to this specific definition",
1926            matching_references.len()
1927        );
1928        matching_references
1929    }
1930
1931    /// Get all undeclared fixture usages for a file
1932    pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
1933        self.undeclared_fixtures
1934            .get(file_path)
1935            .map(|entry| entry.value().clone())
1936            .unwrap_or_default()
1937    }
1938
1939    /// Get all available fixtures for a given file, respecting pytest's fixture hierarchy
1940    /// Returns a list of fixture definitions sorted by name
1941    pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
1942        let mut available_fixtures = Vec::new();
1943        let mut seen_names = std::collections::HashSet::new();
1944
1945        // Priority 1: Fixtures in the same file
1946        for entry in self.definitions.iter() {
1947            let fixture_name = entry.key();
1948            for def in entry.value().iter() {
1949                if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
1950                    available_fixtures.push(def.clone());
1951                    seen_names.insert(fixture_name.clone());
1952                }
1953            }
1954        }
1955
1956        // Priority 2: Fixtures in conftest.py files (walking up the directory tree)
1957        if let Some(mut current_dir) = file_path.parent() {
1958            loop {
1959                let conftest_path = current_dir.join("conftest.py");
1960
1961                for entry in self.definitions.iter() {
1962                    let fixture_name = entry.key();
1963                    for def in entry.value().iter() {
1964                        if def.file_path == conftest_path
1965                            && !seen_names.contains(fixture_name.as_str())
1966                        {
1967                            available_fixtures.push(def.clone());
1968                            seen_names.insert(fixture_name.clone());
1969                        }
1970                    }
1971                }
1972
1973                // Move up one directory
1974                match current_dir.parent() {
1975                    Some(parent) => current_dir = parent,
1976                    None => break,
1977                }
1978            }
1979        }
1980
1981        // Priority 3: Third-party fixtures from site-packages
1982        for entry in self.definitions.iter() {
1983            let fixture_name = entry.key();
1984            for def in entry.value().iter() {
1985                if def.file_path.to_string_lossy().contains("site-packages")
1986                    && !seen_names.contains(fixture_name.as_str())
1987                {
1988                    available_fixtures.push(def.clone());
1989                    seen_names.insert(fixture_name.clone());
1990                }
1991            }
1992        }
1993
1994        // Sort by name for consistent ordering
1995        available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
1996        available_fixtures
1997    }
1998
1999    /// Check if a position is inside a test or fixture function (parameter or body)
2000    /// Returns Some((function_name, is_fixture, declared_params)) if inside a function
2001    pub fn is_inside_function(
2002        &self,
2003        file_path: &Path,
2004        line: u32,
2005        character: u32,
2006    ) -> Option<(String, bool, Vec<String>)> {
2007        // Try cache first, then file system
2008        let content: Arc<String> = if let Some(cached) = self.file_cache.get(file_path) {
2009            Arc::clone(cached.value())
2010        } else {
2011            Arc::new(std::fs::read_to_string(file_path).ok()?)
2012        };
2013
2014        let target_line = (line + 1) as usize; // Convert to 1-based
2015
2016        // Parse the file
2017        let parsed = parse(&content, Mode::Module, "").ok()?;
2018
2019        if let rustpython_parser::ast::Mod::Module(module) = parsed {
2020            return self.find_enclosing_function(
2021                &module.body,
2022                &content,
2023                target_line,
2024                character as usize,
2025            );
2026        }
2027
2028        None
2029    }
2030
2031    fn find_enclosing_function(
2032        &self,
2033        stmts: &[Stmt],
2034        content: &str,
2035        target_line: usize,
2036        _target_char: usize,
2037    ) -> Option<(String, bool, Vec<String>)> {
2038        for stmt in stmts {
2039            match stmt {
2040                Stmt::FunctionDef(func_def) => {
2041                    let func_start_line = content[..func_def.range.start().to_usize()]
2042                        .matches('\n')
2043                        .count()
2044                        + 1;
2045                    let func_end_line = content[..func_def.range.end().to_usize()]
2046                        .matches('\n')
2047                        .count()
2048                        + 1;
2049
2050                    // Check if target is within this function's range
2051                    if target_line >= func_start_line && target_line <= func_end_line {
2052                        let is_fixture = func_def
2053                            .decorator_list
2054                            .iter()
2055                            .any(Self::is_fixture_decorator);
2056                        let is_test = func_def.name.starts_with("test_");
2057
2058                        // Only return if it's a test or fixture
2059                        if is_test || is_fixture {
2060                            let params: Vec<String> = func_def
2061                                .args
2062                                .args
2063                                .iter()
2064                                .map(|arg| arg.def.arg.to_string())
2065                                .collect();
2066
2067                            return Some((func_def.name.to_string(), is_fixture, params));
2068                        }
2069                    }
2070                }
2071                Stmt::AsyncFunctionDef(func_def) => {
2072                    let func_start_line = content[..func_def.range.start().to_usize()]
2073                        .matches('\n')
2074                        .count()
2075                        + 1;
2076                    let func_end_line = content[..func_def.range.end().to_usize()]
2077                        .matches('\n')
2078                        .count()
2079                        + 1;
2080
2081                    if target_line >= func_start_line && target_line <= func_end_line {
2082                        let is_fixture = func_def
2083                            .decorator_list
2084                            .iter()
2085                            .any(Self::is_fixture_decorator);
2086                        let is_test = func_def.name.starts_with("test_");
2087
2088                        if is_test || is_fixture {
2089                            let params: Vec<String> = func_def
2090                                .args
2091                                .args
2092                                .iter()
2093                                .map(|arg| arg.def.arg.to_string())
2094                                .collect();
2095
2096                            return Some((func_def.name.to_string(), is_fixture, params));
2097                        }
2098                    }
2099                }
2100                _ => {}
2101            }
2102        }
2103
2104        None
2105    }
2106}
2107
2108#[cfg(test)]
2109mod tests {
2110    use super::*;
2111    use std::path::PathBuf;
2112
2113    #[test]
2114    fn test_fixture_definition_detection() {
2115        let db = FixtureDatabase::new();
2116
2117        let conftest_content = r#"
2118import pytest
2119
2120@pytest.fixture
2121def my_fixture():
2122    return 42
2123
2124@fixture
2125def another_fixture():
2126    return "hello"
2127"#;
2128
2129        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2130        db.analyze_file(conftest_path.clone(), conftest_content);
2131
2132        // Check that fixtures were detected
2133        assert!(db.definitions.contains_key("my_fixture"));
2134        assert!(db.definitions.contains_key("another_fixture"));
2135
2136        // Check fixture details
2137        let my_fixture_defs = db.definitions.get("my_fixture").unwrap();
2138        assert_eq!(my_fixture_defs.len(), 1);
2139        assert_eq!(my_fixture_defs[0].name, "my_fixture");
2140        assert_eq!(my_fixture_defs[0].file_path, conftest_path);
2141    }
2142
2143    #[test]
2144    fn test_fixture_usage_detection() {
2145        let db = FixtureDatabase::new();
2146
2147        let test_content = r#"
2148def test_something(my_fixture, another_fixture):
2149    assert my_fixture == 42
2150    assert another_fixture == "hello"
2151
2152def test_other(my_fixture):
2153    assert my_fixture > 0
2154"#;
2155
2156        let test_path = PathBuf::from("/tmp/test/test_example.py");
2157        db.analyze_file(test_path.clone(), test_content);
2158
2159        // Check that usages were detected
2160        assert!(db.usages.contains_key(&test_path));
2161
2162        let usages = db.usages.get(&test_path).unwrap();
2163        // Should have usages from the first test function (we only track one function per file currently)
2164        assert!(usages.iter().any(|u| u.name == "my_fixture"));
2165        assert!(usages.iter().any(|u| u.name == "another_fixture"));
2166    }
2167
2168    #[test]
2169    fn test_go_to_definition() {
2170        let db = FixtureDatabase::new();
2171
2172        // Set up conftest.py with a fixture
2173        let conftest_content = r#"
2174import pytest
2175
2176@pytest.fixture
2177def my_fixture():
2178    return 42
2179"#;
2180
2181        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2182        db.analyze_file(conftest_path.clone(), conftest_content);
2183
2184        // Set up a test file that uses the fixture
2185        let test_content = r#"
2186def test_something(my_fixture):
2187    assert my_fixture == 42
2188"#;
2189
2190        let test_path = PathBuf::from("/tmp/test/test_example.py");
2191        db.analyze_file(test_path.clone(), test_content);
2192
2193        // Try to find the definition from the test file
2194        // The usage is on line 2 (1-indexed) - that's where the function parameter is
2195        // In 0-indexed LSP coordinates, that's line 1
2196        // Character position 19 is where 'my_fixture' starts
2197        let definition = db.find_fixture_definition(&test_path, 1, 19);
2198
2199        assert!(definition.is_some(), "Definition should be found");
2200        let def = definition.unwrap();
2201        assert_eq!(def.name, "my_fixture");
2202        assert_eq!(def.file_path, conftest_path);
2203    }
2204
2205    #[test]
2206    fn test_fixture_decorator_variations() {
2207        let db = FixtureDatabase::new();
2208
2209        let conftest_content = r#"
2210import pytest
2211from pytest import fixture
2212
2213@pytest.fixture
2214def fixture1():
2215    pass
2216
2217@pytest.fixture()
2218def fixture2():
2219    pass
2220
2221@fixture
2222def fixture3():
2223    pass
2224
2225@fixture()
2226def fixture4():
2227    pass
2228"#;
2229
2230        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2231        db.analyze_file(conftest_path, conftest_content);
2232
2233        // Check all variations were detected
2234        assert!(db.definitions.contains_key("fixture1"));
2235        assert!(db.definitions.contains_key("fixture2"));
2236        assert!(db.definitions.contains_key("fixture3"));
2237        assert!(db.definitions.contains_key("fixture4"));
2238    }
2239
2240    #[test]
2241    fn test_fixture_in_test_file() {
2242        let db = FixtureDatabase::new();
2243
2244        // Test file with fixture defined in the same file
2245        let test_content = r#"
2246import pytest
2247
2248@pytest.fixture
2249def local_fixture():
2250    return 42
2251
2252def test_something(local_fixture):
2253    assert local_fixture == 42
2254"#;
2255
2256        let test_path = PathBuf::from("/tmp/test/test_example.py");
2257        db.analyze_file(test_path.clone(), test_content);
2258
2259        // Check that fixture was detected even though it's not in conftest.py
2260        assert!(db.definitions.contains_key("local_fixture"));
2261
2262        let local_fixture_defs = db.definitions.get("local_fixture").unwrap();
2263        assert_eq!(local_fixture_defs.len(), 1);
2264        assert_eq!(local_fixture_defs[0].name, "local_fixture");
2265        assert_eq!(local_fixture_defs[0].file_path, test_path);
2266
2267        // Check that usage was detected
2268        assert!(db.usages.contains_key(&test_path));
2269        let usages = db.usages.get(&test_path).unwrap();
2270        assert!(usages.iter().any(|u| u.name == "local_fixture"));
2271
2272        // Test go-to-definition for fixture in same file
2273        let usage_line = usages
2274            .iter()
2275            .find(|u| u.name == "local_fixture")
2276            .map(|u| u.line)
2277            .unwrap();
2278
2279        // Character position 19 is where 'local_fixture' starts in "def test_something(local_fixture):"
2280        let definition = db.find_fixture_definition(&test_path, (usage_line - 1) as u32, 19);
2281        assert!(
2282            definition.is_some(),
2283            "Should find definition for fixture in same file. Line: {}, char: 19",
2284            usage_line
2285        );
2286        let def = definition.unwrap();
2287        assert_eq!(def.name, "local_fixture");
2288        assert_eq!(def.file_path, test_path);
2289    }
2290
2291    #[test]
2292    fn test_async_test_functions() {
2293        let db = FixtureDatabase::new();
2294
2295        // Test file with async test function
2296        let test_content = r#"
2297import pytest
2298
2299@pytest.fixture
2300def my_fixture():
2301    return 42
2302
2303async def test_async_function(my_fixture):
2304    assert my_fixture == 42
2305
2306def test_sync_function(my_fixture):
2307    assert my_fixture == 42
2308"#;
2309
2310        let test_path = PathBuf::from("/tmp/test/test_async.py");
2311        db.analyze_file(test_path.clone(), test_content);
2312
2313        // Check that fixture was detected
2314        assert!(db.definitions.contains_key("my_fixture"));
2315
2316        // Check that both async and sync test functions have their usages detected
2317        assert!(db.usages.contains_key(&test_path));
2318        let usages = db.usages.get(&test_path).unwrap();
2319
2320        // Should have 2 usages (one from async, one from sync)
2321        let fixture_usages: Vec<_> = usages.iter().filter(|u| u.name == "my_fixture").collect();
2322        assert_eq!(
2323            fixture_usages.len(),
2324            2,
2325            "Should detect fixture usage in both async and sync tests"
2326        );
2327    }
2328
2329    #[test]
2330    fn test_extract_word_at_position() {
2331        let db = FixtureDatabase::new();
2332
2333        // Test basic word extraction
2334        let line = "def test_something(my_fixture):";
2335
2336        // Cursor on 'm' of 'my_fixture' (position 19)
2337        assert_eq!(
2338            db.extract_word_at_position(line, 19),
2339            Some("my_fixture".to_string())
2340        );
2341
2342        // Cursor on 'y' of 'my_fixture' (position 20)
2343        assert_eq!(
2344            db.extract_word_at_position(line, 20),
2345            Some("my_fixture".to_string())
2346        );
2347
2348        // Cursor on last 'e' of 'my_fixture' (position 28)
2349        assert_eq!(
2350            db.extract_word_at_position(line, 28),
2351            Some("my_fixture".to_string())
2352        );
2353
2354        // Cursor on 'd' of 'def' (position 0)
2355        assert_eq!(
2356            db.extract_word_at_position(line, 0),
2357            Some("def".to_string())
2358        );
2359
2360        // Cursor on space after 'def' (position 3) - should return None
2361        assert_eq!(db.extract_word_at_position(line, 3), None);
2362
2363        // Cursor on 't' of 'test_something' (position 4)
2364        assert_eq!(
2365            db.extract_word_at_position(line, 4),
2366            Some("test_something".to_string())
2367        );
2368
2369        // Cursor on opening parenthesis (position 18) - should return None
2370        assert_eq!(db.extract_word_at_position(line, 18), None);
2371
2372        // Cursor on closing parenthesis (position 29) - should return None
2373        assert_eq!(db.extract_word_at_position(line, 29), None);
2374
2375        // Cursor on colon (position 31) - should return None
2376        assert_eq!(db.extract_word_at_position(line, 31), None);
2377    }
2378
2379    #[test]
2380    fn test_extract_word_at_position_fixture_definition() {
2381        let db = FixtureDatabase::new();
2382
2383        let line = "@pytest.fixture";
2384
2385        // Cursor on '@' - should return None
2386        assert_eq!(db.extract_word_at_position(line, 0), None);
2387
2388        // Cursor on 'p' of 'pytest' (position 1)
2389        assert_eq!(
2390            db.extract_word_at_position(line, 1),
2391            Some("pytest".to_string())
2392        );
2393
2394        // Cursor on '.' - should return None
2395        assert_eq!(db.extract_word_at_position(line, 7), None);
2396
2397        // Cursor on 'f' of 'fixture' (position 8)
2398        assert_eq!(
2399            db.extract_word_at_position(line, 8),
2400            Some("fixture".to_string())
2401        );
2402
2403        let line2 = "def foo(other_fixture):";
2404
2405        // Cursor on 'd' of 'def'
2406        assert_eq!(
2407            db.extract_word_at_position(line2, 0),
2408            Some("def".to_string())
2409        );
2410
2411        // Cursor on space after 'def' - should return None
2412        assert_eq!(db.extract_word_at_position(line2, 3), None);
2413
2414        // Cursor on 'f' of 'foo'
2415        assert_eq!(
2416            db.extract_word_at_position(line2, 4),
2417            Some("foo".to_string())
2418        );
2419
2420        // Cursor on 'o' of 'other_fixture'
2421        assert_eq!(
2422            db.extract_word_at_position(line2, 8),
2423            Some("other_fixture".to_string())
2424        );
2425
2426        // Cursor on parenthesis - should return None
2427        assert_eq!(db.extract_word_at_position(line2, 7), None);
2428    }
2429
2430    #[test]
2431    fn test_word_detection_only_on_fixtures() {
2432        let db = FixtureDatabase::new();
2433
2434        // Set up a conftest with a fixture
2435        let conftest_content = r#"
2436import pytest
2437
2438@pytest.fixture
2439def my_fixture():
2440    return 42
2441"#;
2442        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2443        db.analyze_file(conftest_path.clone(), conftest_content);
2444
2445        // Set up a test file
2446        let test_content = r#"
2447def test_something(my_fixture, regular_param):
2448    assert my_fixture == 42
2449"#;
2450        let test_path = PathBuf::from("/tmp/test/test_example.py");
2451        db.analyze_file(test_path.clone(), test_content);
2452
2453        // Line 2 is "def test_something(my_fixture, regular_param):"
2454        // Character positions:
2455        // 0: 'd' of 'def'
2456        // 4: 't' of 'test_something'
2457        // 19: 'm' of 'my_fixture'
2458        // 31: 'r' of 'regular_param'
2459
2460        // Cursor on 'def' - should NOT find a fixture (LSP line 1, 0-based)
2461        assert_eq!(db.find_fixture_definition(&test_path, 1, 0), None);
2462
2463        // Cursor on 'test_something' - should NOT find a fixture
2464        assert_eq!(db.find_fixture_definition(&test_path, 1, 4), None);
2465
2466        // Cursor on 'my_fixture' - SHOULD find the fixture
2467        let result = db.find_fixture_definition(&test_path, 1, 19);
2468        assert!(result.is_some());
2469        let def = result.unwrap();
2470        assert_eq!(def.name, "my_fixture");
2471
2472        // Cursor on 'regular_param' - should NOT find a fixture (it's not a fixture)
2473        assert_eq!(db.find_fixture_definition(&test_path, 1, 31), None);
2474
2475        // Cursor on comma or parenthesis - should NOT find a fixture
2476        assert_eq!(db.find_fixture_definition(&test_path, 1, 18), None); // '('
2477        assert_eq!(db.find_fixture_definition(&test_path, 1, 29), None); // ','
2478    }
2479
2480    #[test]
2481    fn test_self_referencing_fixture() {
2482        let db = FixtureDatabase::new();
2483
2484        // Set up a parent conftest.py with the original fixture
2485        let parent_conftest_content = r#"
2486import pytest
2487
2488@pytest.fixture
2489def foo():
2490    return "parent"
2491"#;
2492        let parent_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2493        db.analyze_file(parent_conftest_path.clone(), parent_conftest_content);
2494
2495        // Set up a child directory conftest.py that overrides foo, referencing itself
2496        let child_conftest_content = r#"
2497import pytest
2498
2499@pytest.fixture
2500def foo(foo):
2501    return foo + " child"
2502"#;
2503        let child_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2504        db.analyze_file(child_conftest_path.clone(), child_conftest_content);
2505
2506        // Now test go-to-definition on the parameter `foo` in the child fixture
2507        // Line 5 is "def foo(foo):" (1-indexed)
2508        // Character position 8 is the 'f' in the parameter name "foo"
2509        // LSP uses 0-indexed lines, so line 4 in LSP coordinates
2510
2511        let result = db.find_fixture_definition(&child_conftest_path, 4, 8);
2512
2513        assert!(
2514            result.is_some(),
2515            "Should find parent definition for self-referencing fixture"
2516        );
2517        let def = result.unwrap();
2518        assert_eq!(def.name, "foo");
2519        assert_eq!(
2520            def.file_path, parent_conftest_path,
2521            "Should resolve to parent conftest.py, not the child"
2522        );
2523        assert_eq!(def.line, 5, "Should point to line 5 of parent conftest.py");
2524    }
2525
2526    #[test]
2527    fn test_fixture_overriding_same_file() {
2528        let db = FixtureDatabase::new();
2529
2530        // A test file with multiple fixtures with the same name (unusual but valid)
2531        let test_content = r#"
2532import pytest
2533
2534@pytest.fixture
2535def my_fixture():
2536    return "first"
2537
2538@pytest.fixture
2539def my_fixture():
2540    return "second"
2541
2542def test_something(my_fixture):
2543    assert my_fixture == "second"
2544"#;
2545        let test_path = PathBuf::from("/tmp/test/test_example.py");
2546        db.analyze_file(test_path.clone(), test_content);
2547
2548        // When there are multiple definitions in the same file, the later one should win
2549        // (Python's behavior - later definitions override earlier ones)
2550
2551        // Test go-to-definition on the parameter in test_something
2552        // Line 12 is "def test_something(my_fixture):" (1-indexed)
2553        // Character position 19 is the 'm' in "my_fixture"
2554        // LSP uses 0-indexed lines, so line 11 in LSP coordinates
2555
2556        let result = db.find_fixture_definition(&test_path, 11, 19);
2557
2558        assert!(result.is_some(), "Should find fixture definition");
2559        let def = result.unwrap();
2560        assert_eq!(def.name, "my_fixture");
2561        assert_eq!(def.file_path, test_path);
2562        // The current implementation returns the first match in the same file
2563        // For true Python semantics, we'd want the last one, but that's a more complex change
2564        // For now, we just verify it finds *a* definition in the same file
2565    }
2566
2567    #[test]
2568    fn test_fixture_overriding_conftest_hierarchy() {
2569        let db = FixtureDatabase::new();
2570
2571        // Root conftest.py
2572        let root_conftest_content = r#"
2573import pytest
2574
2575@pytest.fixture
2576def shared_fixture():
2577    return "root"
2578"#;
2579        let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2580        db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2581
2582        // Subdirectory conftest.py that overrides the fixture
2583        let sub_conftest_content = r#"
2584import pytest
2585
2586@pytest.fixture
2587def shared_fixture():
2588    return "subdir"
2589"#;
2590        let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2591        db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2592
2593        // Test file in subdirectory
2594        let test_content = r#"
2595def test_something(shared_fixture):
2596    assert shared_fixture == "subdir"
2597"#;
2598        let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
2599        db.analyze_file(test_path.clone(), test_content);
2600
2601        // Go-to-definition from the test should find the closest conftest.py (subdir)
2602        // Line 2 is "def test_something(shared_fixture):" (1-indexed)
2603        // Character position 19 is the 's' in "shared_fixture"
2604        // LSP uses 0-indexed lines, so line 1 in LSP coordinates
2605
2606        let result = db.find_fixture_definition(&test_path, 1, 19);
2607
2608        assert!(result.is_some(), "Should find fixture definition");
2609        let def = result.unwrap();
2610        assert_eq!(def.name, "shared_fixture");
2611        assert_eq!(
2612            def.file_path, sub_conftest_path,
2613            "Should resolve to closest conftest.py"
2614        );
2615
2616        // Now test from a file in the parent directory
2617        let parent_test_content = r#"
2618def test_parent(shared_fixture):
2619    assert shared_fixture == "root"
2620"#;
2621        let parent_test_path = PathBuf::from("/tmp/test/test_parent.py");
2622        db.analyze_file(parent_test_path.clone(), parent_test_content);
2623
2624        let result = db.find_fixture_definition(&parent_test_path, 1, 16);
2625
2626        assert!(result.is_some(), "Should find fixture definition");
2627        let def = result.unwrap();
2628        assert_eq!(def.name, "shared_fixture");
2629        assert_eq!(
2630            def.file_path, root_conftest_path,
2631            "Should resolve to root conftest.py"
2632        );
2633    }
2634
2635    #[test]
2636    fn test_scoped_references() {
2637        let db = FixtureDatabase::new();
2638
2639        // Set up a root conftest.py with a fixture
2640        let root_conftest_content = r#"
2641import pytest
2642
2643@pytest.fixture
2644def shared_fixture():
2645    return "root"
2646"#;
2647        let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2648        db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2649
2650        // Set up subdirectory conftest.py that overrides the fixture
2651        let sub_conftest_content = r#"
2652import pytest
2653
2654@pytest.fixture
2655def shared_fixture():
2656    return "subdir"
2657"#;
2658        let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2659        db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2660
2661        // Test file in the root directory (uses root fixture)
2662        let root_test_content = r#"
2663def test_root(shared_fixture):
2664    assert shared_fixture == "root"
2665"#;
2666        let root_test_path = PathBuf::from("/tmp/test/test_root.py");
2667        db.analyze_file(root_test_path.clone(), root_test_content);
2668
2669        // Test file in subdirectory (uses subdir fixture)
2670        let sub_test_content = r#"
2671def test_sub(shared_fixture):
2672    assert shared_fixture == "subdir"
2673"#;
2674        let sub_test_path = PathBuf::from("/tmp/test/subdir/test_sub.py");
2675        db.analyze_file(sub_test_path.clone(), sub_test_content);
2676
2677        // Another test in subdirectory
2678        let sub_test2_content = r#"
2679def test_sub2(shared_fixture):
2680    assert shared_fixture == "subdir"
2681"#;
2682        let sub_test2_path = PathBuf::from("/tmp/test/subdir/test_sub2.py");
2683        db.analyze_file(sub_test2_path.clone(), sub_test2_content);
2684
2685        // Get the root definition
2686        let root_definitions = db.definitions.get("shared_fixture").unwrap();
2687        let root_definition = root_definitions
2688            .iter()
2689            .find(|d| d.file_path == root_conftest_path)
2690            .unwrap();
2691
2692        // Get the subdir definition
2693        let sub_definition = root_definitions
2694            .iter()
2695            .find(|d| d.file_path == sub_conftest_path)
2696            .unwrap();
2697
2698        // Find references for the root definition
2699        let root_refs = db.find_references_for_definition(root_definition);
2700
2701        // Should only include the test in the root directory
2702        assert_eq!(
2703            root_refs.len(),
2704            1,
2705            "Root definition should have 1 reference (from root test)"
2706        );
2707        assert_eq!(root_refs[0].file_path, root_test_path);
2708
2709        // Find references for the subdir definition
2710        let sub_refs = db.find_references_for_definition(sub_definition);
2711
2712        // Should include both tests in the subdirectory
2713        assert_eq!(
2714            sub_refs.len(),
2715            2,
2716            "Subdir definition should have 2 references (from subdir tests)"
2717        );
2718
2719        let sub_ref_paths: Vec<_> = sub_refs.iter().map(|r| &r.file_path).collect();
2720        assert!(sub_ref_paths.contains(&&sub_test_path));
2721        assert!(sub_ref_paths.contains(&&sub_test2_path));
2722
2723        // Verify that all references by name returns 3 total
2724        let all_refs = db.find_fixture_references("shared_fixture");
2725        assert_eq!(
2726            all_refs.len(),
2727            3,
2728            "Should find 3 total references across all scopes"
2729        );
2730    }
2731
2732    #[test]
2733    fn test_multiline_parameters() {
2734        let db = FixtureDatabase::new();
2735
2736        // Conftest with fixture
2737        let conftest_content = r#"
2738import pytest
2739
2740@pytest.fixture
2741def foo():
2742    return 42
2743"#;
2744        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2745        db.analyze_file(conftest_path.clone(), conftest_content);
2746
2747        // Test file with multiline parameters
2748        let test_content = r#"
2749def test_xxx(
2750    foo,
2751):
2752    assert foo == 42
2753"#;
2754        let test_path = PathBuf::from("/tmp/test/test_example.py");
2755        db.analyze_file(test_path.clone(), test_content);
2756
2757        // Line 3 (1-indexed) is "    foo," - the parameter line
2758        // In LSP coordinates, that's line 2 (0-indexed)
2759        // Character position 4 is the 'f' in 'foo'
2760
2761        // Debug: Check what usages were recorded
2762        if let Some(usages) = db.usages.get(&test_path) {
2763            println!("Usages recorded:");
2764            for usage in usages.iter() {
2765                println!("  {} at line {} (1-indexed)", usage.name, usage.line);
2766            }
2767        } else {
2768            println!("No usages recorded for test file");
2769        }
2770
2771        // The content has a leading newline, so:
2772        // Line 1: (empty)
2773        // Line 2: def test_xxx(
2774        // Line 3:     foo,
2775        // Line 4: ):
2776        // Line 5:     assert foo == 42
2777
2778        // foo is at line 3 (1-indexed) = line 2 (0-indexed LSP)
2779        let result = db.find_fixture_definition(&test_path, 2, 4);
2780
2781        assert!(
2782            result.is_some(),
2783            "Should find fixture definition when cursor is on parameter line"
2784        );
2785        let def = result.unwrap();
2786        assert_eq!(def.name, "foo");
2787    }
2788
2789    #[test]
2790    fn test_find_references_from_usage() {
2791        let db = FixtureDatabase::new();
2792
2793        // Simple fixture and usage in the same file
2794        let test_content = r#"
2795import pytest
2796
2797@pytest.fixture
2798def foo(): ...
2799
2800
2801def test_xxx(foo):
2802    pass
2803"#;
2804        let test_path = PathBuf::from("/tmp/test/test_example.py");
2805        db.analyze_file(test_path.clone(), test_content);
2806
2807        // Get the foo definition
2808        let foo_defs = db.definitions.get("foo").unwrap();
2809        assert_eq!(foo_defs.len(), 1, "Should have exactly one foo definition");
2810        let foo_def = &foo_defs[0];
2811        assert_eq!(foo_def.line, 5, "foo definition should be on line 5");
2812
2813        // Get references for the definition
2814        let refs_from_def = db.find_references_for_definition(foo_def);
2815        println!("References from definition:");
2816        for r in &refs_from_def {
2817            println!("  {} at line {}", r.name, r.line);
2818        }
2819
2820        assert_eq!(
2821            refs_from_def.len(),
2822            1,
2823            "Should find 1 usage reference (test_xxx parameter)"
2824        );
2825        assert_eq!(refs_from_def[0].line, 8, "Usage should be on line 8");
2826
2827        // Now simulate what happens when user clicks on the usage (line 8, char 13 - the 'f' in 'foo')
2828        // This is LSP line 7 (0-indexed)
2829        let fixture_name = db.find_fixture_at_position(&test_path, 7, 13);
2830        println!(
2831            "\nfind_fixture_at_position(line 7, char 13): {:?}",
2832            fixture_name
2833        );
2834
2835        assert_eq!(
2836            fixture_name,
2837            Some("foo".to_string()),
2838            "Should find fixture name at usage position"
2839        );
2840
2841        let resolved_def = db.find_fixture_definition(&test_path, 7, 13);
2842        println!(
2843            "\nfind_fixture_definition(line 7, char 13): {:?}",
2844            resolved_def.as_ref().map(|d| (d.line, &d.file_path))
2845        );
2846
2847        assert!(resolved_def.is_some(), "Should resolve usage to definition");
2848        assert_eq!(
2849            resolved_def.unwrap(),
2850            *foo_def,
2851            "Should resolve to the correct definition"
2852        );
2853    }
2854
2855    #[test]
2856    fn test_find_references_with_ellipsis_body() {
2857        // This reproduces the structure from strawberry test_codegen.py
2858        let db = FixtureDatabase::new();
2859
2860        let test_content = r#"@pytest.fixture
2861def foo(): ...
2862
2863
2864def test_xxx(foo):
2865    pass
2866"#;
2867        let test_path = PathBuf::from("/tmp/test/test_codegen.py");
2868        db.analyze_file(test_path.clone(), test_content);
2869
2870        // Check what line foo definition is on
2871        let foo_defs = db.definitions.get("foo");
2872        println!(
2873            "foo definitions: {:?}",
2874            foo_defs
2875                .as_ref()
2876                .map(|defs| defs.iter().map(|d| d.line).collect::<Vec<_>>())
2877        );
2878
2879        // Check what line foo usage is on
2880        if let Some(usages) = db.usages.get(&test_path) {
2881            println!("usages:");
2882            for u in usages.iter() {
2883                println!("  {} at line {}", u.name, u.line);
2884            }
2885        }
2886
2887        assert!(foo_defs.is_some(), "Should find foo definition");
2888        let foo_def = &foo_defs.unwrap()[0];
2889
2890        // Get the usage line
2891        let usages = db.usages.get(&test_path).unwrap();
2892        let foo_usage = usages.iter().find(|u| u.name == "foo").unwrap();
2893
2894        // Test from usage position (LSP coordinates are 0-indexed)
2895        let usage_lsp_line = (foo_usage.line - 1) as u32;
2896        println!("\nTesting from usage at LSP line {}", usage_lsp_line);
2897
2898        let fixture_name = db.find_fixture_at_position(&test_path, usage_lsp_line, 13);
2899        assert_eq!(
2900            fixture_name,
2901            Some("foo".to_string()),
2902            "Should find foo at usage"
2903        );
2904
2905        let def_from_usage = db.find_fixture_definition(&test_path, usage_lsp_line, 13);
2906        assert!(
2907            def_from_usage.is_some(),
2908            "Should resolve usage to definition"
2909        );
2910        assert_eq!(def_from_usage.unwrap(), *foo_def);
2911    }
2912
2913    #[test]
2914    fn test_fixture_hierarchy_parent_references() {
2915        // Test that finding references from a parent fixture definition
2916        // includes child fixture definitions but NOT the child's usages
2917        let db = FixtureDatabase::new();
2918
2919        // Parent conftest
2920        let parent_content = r#"
2921import pytest
2922
2923@pytest.fixture
2924def cli_runner():
2925    """Parent fixture"""
2926    return "parent"
2927"#;
2928        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2929        db.analyze_file(parent_conftest.clone(), parent_content);
2930
2931        // Child conftest with override
2932        let child_content = r#"
2933import pytest
2934
2935@pytest.fixture
2936def cli_runner(cli_runner):
2937    """Child override that uses parent"""
2938    return cli_runner
2939"#;
2940        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2941        db.analyze_file(child_conftest.clone(), child_content);
2942
2943        // Test file in subdir using the child fixture
2944        let test_content = r#"
2945def test_one(cli_runner):
2946    pass
2947
2948def test_two(cli_runner):
2949    pass
2950"#;
2951        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2952        db.analyze_file(test_path.clone(), test_content);
2953
2954        // Get parent definition
2955        let parent_defs = db.definitions.get("cli_runner").unwrap();
2956        let parent_def = parent_defs
2957            .iter()
2958            .find(|d| d.file_path == parent_conftest)
2959            .unwrap();
2960
2961        println!(
2962            "\nParent definition: {:?}:{}",
2963            parent_def.file_path, parent_def.line
2964        );
2965
2966        // Find references for parent definition
2967        let refs = db.find_references_for_definition(parent_def);
2968
2969        println!("\nReferences for parent definition:");
2970        for r in &refs {
2971            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2972        }
2973
2974        // Parent references should include:
2975        // 1. The child fixture definition (line 5 in child conftest)
2976        // 2. The child's parameter that references the parent (line 5 in child conftest)
2977        // But NOT:
2978        // 3. test_one and test_two usages (they resolve to child, not parent)
2979
2980        assert!(
2981            refs.len() <= 2,
2982            "Parent should have at most 2 references: child definition and its parameter, got {}",
2983            refs.len()
2984        );
2985
2986        // Should include the child conftest
2987        let child_refs: Vec<_> = refs
2988            .iter()
2989            .filter(|r| r.file_path == child_conftest)
2990            .collect();
2991        assert!(
2992            !child_refs.is_empty(),
2993            "Parent references should include child fixture definition"
2994        );
2995
2996        // Should NOT include test file usages
2997        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2998        assert!(
2999            test_refs.is_empty(),
3000            "Parent references should NOT include child's test file usages"
3001        );
3002    }
3003
3004    #[test]
3005    fn test_fixture_hierarchy_child_references() {
3006        // Test that finding references from a child fixture definition
3007        // includes usages in the same directory (that resolve to the child)
3008        let db = FixtureDatabase::new();
3009
3010        // Parent conftest
3011        let parent_content = r#"
3012import pytest
3013
3014@pytest.fixture
3015def cli_runner():
3016    return "parent"
3017"#;
3018        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
3019        db.analyze_file(parent_conftest.clone(), parent_content);
3020
3021        // Child conftest with override
3022        let child_content = r#"
3023import pytest
3024
3025@pytest.fixture
3026def cli_runner(cli_runner):
3027    return cli_runner
3028"#;
3029        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
3030        db.analyze_file(child_conftest.clone(), child_content);
3031
3032        // Test file using child fixture
3033        let test_content = r#"
3034def test_one(cli_runner):
3035    pass
3036
3037def test_two(cli_runner):
3038    pass
3039"#;
3040        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
3041        db.analyze_file(test_path.clone(), test_content);
3042
3043        // Get child definition
3044        let child_defs = db.definitions.get("cli_runner").unwrap();
3045        let child_def = child_defs
3046            .iter()
3047            .find(|d| d.file_path == child_conftest)
3048            .unwrap();
3049
3050        println!(
3051            "\nChild definition: {:?}:{}",
3052            child_def.file_path, child_def.line
3053        );
3054
3055        // Find references for child definition
3056        let refs = db.find_references_for_definition(child_def);
3057
3058        println!("\nReferences for child definition:");
3059        for r in &refs {
3060            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
3061        }
3062
3063        // Child references should include test_one and test_two
3064        assert!(
3065            refs.len() >= 2,
3066            "Child should have at least 2 references from test file, got {}",
3067            refs.len()
3068        );
3069
3070        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
3071        assert_eq!(
3072            test_refs.len(),
3073            2,
3074            "Should have 2 references from test file"
3075        );
3076    }
3077
3078    #[test]
3079    fn test_fixture_hierarchy_child_parameter_references() {
3080        // Test that finding references from a child fixture's parameter
3081        // (which references the parent) includes the child fixture definition
3082        let db = FixtureDatabase::new();
3083
3084        // Parent conftest
3085        let parent_content = r#"
3086import pytest
3087
3088@pytest.fixture
3089def cli_runner():
3090    return "parent"
3091"#;
3092        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
3093        db.analyze_file(parent_conftest.clone(), parent_content);
3094
3095        // Child conftest with override
3096        let child_content = r#"
3097import pytest
3098
3099@pytest.fixture
3100def cli_runner(cli_runner):
3101    return cli_runner
3102"#;
3103        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
3104        db.analyze_file(child_conftest.clone(), child_content);
3105
3106        // When user clicks on the parameter "cli_runner" in the child definition,
3107        // it should resolve to the parent definition
3108        // Line 5 (1-indexed) = line 4 (0-indexed LSP), char 15 is in the parameter name
3109        let resolved_def = db.find_fixture_definition(&child_conftest, 4, 15);
3110
3111        assert!(
3112            resolved_def.is_some(),
3113            "Child parameter should resolve to parent definition"
3114        );
3115
3116        let def = resolved_def.unwrap();
3117        assert_eq!(
3118            def.file_path, parent_conftest,
3119            "Should resolve to parent conftest"
3120        );
3121
3122        // Find references for parent definition
3123        let refs = db.find_references_for_definition(&def);
3124
3125        println!("\nReferences for parent (from child parameter):");
3126        for r in &refs {
3127            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
3128        }
3129
3130        // Should include the child fixture's parameter usage
3131        let child_refs: Vec<_> = refs
3132            .iter()
3133            .filter(|r| r.file_path == child_conftest)
3134            .collect();
3135        assert!(
3136            !child_refs.is_empty(),
3137            "Parent references should include child fixture parameter"
3138        );
3139    }
3140
3141    #[test]
3142    fn test_fixture_hierarchy_usage_from_test() {
3143        // Test that finding references from a test function parameter
3144        // includes the definition it resolves to and other usages
3145        let db = FixtureDatabase::new();
3146
3147        // Parent conftest
3148        let parent_content = r#"
3149import pytest
3150
3151@pytest.fixture
3152def cli_runner():
3153    return "parent"
3154"#;
3155        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
3156        db.analyze_file(parent_conftest.clone(), parent_content);
3157
3158        // Child conftest with override
3159        let child_content = r#"
3160import pytest
3161
3162@pytest.fixture
3163def cli_runner(cli_runner):
3164    return cli_runner
3165"#;
3166        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
3167        db.analyze_file(child_conftest.clone(), child_content);
3168
3169        // Test file using child fixture
3170        let test_content = r#"
3171def test_one(cli_runner):
3172    pass
3173
3174def test_two(cli_runner):
3175    pass
3176
3177def test_three(cli_runner):
3178    pass
3179"#;
3180        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
3181        db.analyze_file(test_path.clone(), test_content);
3182
3183        // Click on cli_runner in test_one (line 2, 1-indexed = line 1, 0-indexed)
3184        let resolved_def = db.find_fixture_definition(&test_path, 1, 13);
3185
3186        assert!(
3187            resolved_def.is_some(),
3188            "Usage should resolve to child definition"
3189        );
3190
3191        let def = resolved_def.unwrap();
3192        assert_eq!(
3193            def.file_path, child_conftest,
3194            "Should resolve to child conftest (not parent)"
3195        );
3196
3197        // Find references for the resolved definition
3198        let refs = db.find_references_for_definition(&def);
3199
3200        println!("\nReferences for child (from test usage):");
3201        for r in &refs {
3202            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
3203        }
3204
3205        // Should include all three test usages
3206        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
3207        assert_eq!(test_refs.len(), 3, "Should find all 3 usages in test file");
3208    }
3209
3210    #[test]
3211    fn test_fixture_hierarchy_multiple_levels() {
3212        // Test a three-level hierarchy: grandparent -> parent -> child
3213        let db = FixtureDatabase::new();
3214
3215        // Grandparent
3216        let grandparent_content = r#"
3217import pytest
3218
3219@pytest.fixture
3220def db():
3221    return "grandparent_db"
3222"#;
3223        let grandparent_conftest = PathBuf::from("/tmp/project/conftest.py");
3224        db.analyze_file(grandparent_conftest.clone(), grandparent_content);
3225
3226        // Parent overrides
3227        let parent_content = r#"
3228import pytest
3229
3230@pytest.fixture
3231def db(db):
3232    return f"parent_{db}"
3233"#;
3234        let parent_conftest = PathBuf::from("/tmp/project/api/conftest.py");
3235        db.analyze_file(parent_conftest.clone(), parent_content);
3236
3237        // Child overrides again
3238        let child_content = r#"
3239import pytest
3240
3241@pytest.fixture
3242def db(db):
3243    return f"child_{db}"
3244"#;
3245        let child_conftest = PathBuf::from("/tmp/project/api/tests/conftest.py");
3246        db.analyze_file(child_conftest.clone(), child_content);
3247
3248        // Test file at child level
3249        let test_content = r#"
3250def test_db(db):
3251    pass
3252"#;
3253        let test_path = PathBuf::from("/tmp/project/api/tests/test_example.py");
3254        db.analyze_file(test_path.clone(), test_content);
3255
3256        // Get all definitions
3257        let all_defs = db.definitions.get("db").unwrap();
3258        assert_eq!(all_defs.len(), 3, "Should have 3 definitions");
3259
3260        let grandparent_def = all_defs
3261            .iter()
3262            .find(|d| d.file_path == grandparent_conftest)
3263            .unwrap();
3264        let parent_def = all_defs
3265            .iter()
3266            .find(|d| d.file_path == parent_conftest)
3267            .unwrap();
3268        let child_def = all_defs
3269            .iter()
3270            .find(|d| d.file_path == child_conftest)
3271            .unwrap();
3272
3273        // Test from test file - should resolve to child
3274        let resolved = db.find_fixture_definition(&test_path, 1, 12);
3275        assert_eq!(
3276            resolved.as_ref(),
3277            Some(child_def),
3278            "Test should use child definition"
3279        );
3280
3281        // Child's references should include test file
3282        let child_refs = db.find_references_for_definition(child_def);
3283        let test_refs: Vec<_> = child_refs
3284            .iter()
3285            .filter(|r| r.file_path == test_path)
3286            .collect();
3287        assert!(
3288            !test_refs.is_empty(),
3289            "Child should have test file references"
3290        );
3291
3292        // Parent's references should include child's parameter, but not test file
3293        let parent_refs = db.find_references_for_definition(parent_def);
3294        let child_param_refs: Vec<_> = parent_refs
3295            .iter()
3296            .filter(|r| r.file_path == child_conftest)
3297            .collect();
3298        let test_refs_in_parent: Vec<_> = parent_refs
3299            .iter()
3300            .filter(|r| r.file_path == test_path)
3301            .collect();
3302
3303        assert!(
3304            !child_param_refs.is_empty(),
3305            "Parent should have child parameter reference"
3306        );
3307        assert!(
3308            test_refs_in_parent.is_empty(),
3309            "Parent should NOT have test file references"
3310        );
3311
3312        // Grandparent's references should include parent's parameter, but not child's stuff
3313        let grandparent_refs = db.find_references_for_definition(grandparent_def);
3314        let parent_param_refs: Vec<_> = grandparent_refs
3315            .iter()
3316            .filter(|r| r.file_path == parent_conftest)
3317            .collect();
3318        let child_refs_in_gp: Vec<_> = grandparent_refs
3319            .iter()
3320            .filter(|r| r.file_path == child_conftest)
3321            .collect();
3322
3323        assert!(
3324            !parent_param_refs.is_empty(),
3325            "Grandparent should have parent parameter reference"
3326        );
3327        assert!(
3328            child_refs_in_gp.is_empty(),
3329            "Grandparent should NOT have child references"
3330        );
3331    }
3332
3333    #[test]
3334    fn test_fixture_hierarchy_same_file_override() {
3335        // Test that a fixture can be overridden in the same file
3336        // (less common but valid pytest pattern)
3337        let db = FixtureDatabase::new();
3338
3339        let content = r#"
3340import pytest
3341
3342@pytest.fixture
3343def base():
3344    return "base"
3345
3346@pytest.fixture
3347def base(base):
3348    return f"override_{base}"
3349
3350def test_uses_override(base):
3351    pass
3352"#;
3353        let test_path = PathBuf::from("/tmp/test/test_example.py");
3354        db.analyze_file(test_path.clone(), content);
3355
3356        let defs = db.definitions.get("base").unwrap();
3357        assert_eq!(defs.len(), 2, "Should have 2 definitions in same file");
3358
3359        println!("\nDefinitions found:");
3360        for d in defs.iter() {
3361            println!("  base at line {}", d.line);
3362        }
3363
3364        // Check usages
3365        if let Some(usages) = db.usages.get(&test_path) {
3366            println!("\nUsages found:");
3367            for u in usages.iter() {
3368                println!("  {} at line {}", u.name, u.line);
3369            }
3370        } else {
3371            println!("\nNo usages found!");
3372        }
3373
3374        // The test should resolve to the second definition (override)
3375        // Line 12 (1-indexed) = line 11 (0-indexed LSP)
3376        // Character position: "def test_uses_override(base):" - 'b' is at position 23
3377        let resolved = db.find_fixture_definition(&test_path, 11, 23);
3378
3379        println!("\nResolved: {:?}", resolved.as_ref().map(|d| d.line));
3380
3381        assert!(resolved.is_some(), "Should resolve to override definition");
3382
3383        // The second definition should be at line 9 (1-indexed)
3384        let override_def = defs.iter().find(|d| d.line == 9).unwrap();
3385        println!("Override def at line: {}", override_def.line);
3386        assert_eq!(resolved.as_ref(), Some(override_def));
3387    }
3388
3389    #[test]
3390    fn test_cursor_position_on_definition_line() {
3391        // Debug test to understand what happens at different cursor positions
3392        // on a fixture definition line with a self-referencing parameter
3393        let db = FixtureDatabase::new();
3394
3395        // Add a parent conftest with parent fixture
3396        let parent_content = r#"
3397import pytest
3398
3399@pytest.fixture
3400def cli_runner():
3401    return "parent"
3402"#;
3403        let parent_conftest = PathBuf::from("/tmp/conftest.py");
3404        db.analyze_file(parent_conftest.clone(), parent_content);
3405
3406        let content = r#"
3407import pytest
3408
3409@pytest.fixture
3410def cli_runner(cli_runner):
3411    return cli_runner
3412"#;
3413        let test_path = PathBuf::from("/tmp/test/test_example.py");
3414        db.analyze_file(test_path.clone(), content);
3415
3416        // Line 5 (1-indexed): "def cli_runner(cli_runner):"
3417        // Position 0: 'd' in def
3418        // Position 4: 'c' in cli_runner (function name)
3419        // Position 15: '('
3420        // Position 16: 'c' in cli_runner (parameter name)
3421
3422        println!("\n=== Testing character positions on line 5 ===");
3423
3424        // Check usages
3425        if let Some(usages) = db.usages.get(&test_path) {
3426            println!("\nUsages found:");
3427            for u in usages.iter() {
3428                println!(
3429                    "  {} at line {}, chars {}-{}",
3430                    u.name, u.line, u.start_char, u.end_char
3431                );
3432            }
3433        } else {
3434            println!("\nNo usages found!");
3435        }
3436
3437        // Test clicking on function name 'cli_runner' - should be treated as definition
3438        let line_content = "def cli_runner(cli_runner):";
3439        println!("\nLine content: '{}'", line_content);
3440
3441        // Position 4 = 'c' in function name cli_runner
3442        println!("\nPosition 4 (function name):");
3443        let word_at_4 = db.extract_word_at_position(line_content, 4);
3444        println!("  Word at cursor: {:?}", word_at_4);
3445        let fixture_name_at_4 = db.find_fixture_at_position(&test_path, 4, 4);
3446        println!("  find_fixture_at_position: {:?}", fixture_name_at_4);
3447        let resolved_4 = db.find_fixture_definition(&test_path, 4, 4); // Line 5 = index 4
3448        println!(
3449            "  Resolved: {:?}",
3450            resolved_4.as_ref().map(|d| (d.name.as_str(), d.line))
3451        );
3452
3453        // Position 16 = 'c' in parameter name cli_runner
3454        println!("\nPosition 16 (parameter name):");
3455        let word_at_16 = db.extract_word_at_position(line_content, 16);
3456        println!("  Word at cursor: {:?}", word_at_16);
3457
3458        // Manual check: does the usage check work?
3459        if let Some(usages) = db.usages.get(&test_path) {
3460            for usage in usages.iter() {
3461                println!("  Checking usage: {} at line {}", usage.name, usage.line);
3462                if usage.line == 5 && usage.name == "cli_runner" {
3463                    println!("    MATCH! Usage matches our position");
3464                }
3465            }
3466        }
3467
3468        let fixture_name_at_16 = db.find_fixture_at_position(&test_path, 4, 16);
3469        println!("  find_fixture_at_position: {:?}", fixture_name_at_16);
3470        let resolved_16 = db.find_fixture_definition(&test_path, 4, 16); // Line 5 = index 4
3471        println!(
3472            "  Resolved: {:?}",
3473            resolved_16.as_ref().map(|d| (d.name.as_str(), d.line))
3474        );
3475
3476        // Expected behavior:
3477        // - Position 4 (function name): should resolve to CHILD (line 5) - we're ON the definition
3478        // - Position 16 (parameter): should resolve to PARENT (line 5 in conftest) - it's a usage
3479
3480        assert_eq!(word_at_4, Some("cli_runner".to_string()));
3481        assert_eq!(word_at_16, Some("cli_runner".to_string()));
3482
3483        // Check the actual resolution
3484        println!("\n=== ACTUAL vs EXPECTED ===");
3485        println!("Position 4 (function name):");
3486        println!(
3487            "  Actual: {:?}",
3488            resolved_4.as_ref().map(|d| (&d.file_path, d.line))
3489        );
3490        println!("  Expected: test file, line 5 (the child definition itself)");
3491
3492        println!("\nPosition 16 (parameter):");
3493        println!(
3494            "  Actual: {:?}",
3495            resolved_16.as_ref().map(|d| (&d.file_path, d.line))
3496        );
3497        println!("  Expected: conftest, line 5 (the parent definition)");
3498
3499        // The BUG: both return the same thing (child at line 5)
3500        // Position 4: returning child is CORRECT (though find_fixture_definition returns None,
3501        //             main.rs falls back to get_definition_at_line which is correct)
3502        // Position 16: returning child is WRONG - should return parent (line 5 in conftest)
3503
3504        if let Some(ref def) = resolved_16 {
3505            assert_eq!(
3506                def.file_path, parent_conftest,
3507                "Parameter should resolve to parent definition"
3508            );
3509        } else {
3510            panic!("Position 16 (parameter) should resolve to parent definition");
3511        }
3512    }
3513
3514    #[test]
3515    fn test_undeclared_fixture_detection_in_test() {
3516        let db = FixtureDatabase::new();
3517
3518        // Add a fixture definition in conftest
3519        let conftest_content = r#"
3520import pytest
3521
3522@pytest.fixture
3523def my_fixture():
3524    return 42
3525"#;
3526        let conftest_path = PathBuf::from("/tmp/conftest.py");
3527        db.analyze_file(conftest_path.clone(), conftest_content);
3528
3529        // Add a test that uses the fixture without declaring it
3530        let test_content = r#"
3531def test_example():
3532    result = my_fixture.get()
3533    assert result == 42
3534"#;
3535        let test_path = PathBuf::from("/tmp/test_example.py");
3536        db.analyze_file(test_path.clone(), test_content);
3537
3538        // Check that undeclared fixture was detected
3539        let undeclared = db.get_undeclared_fixtures(&test_path);
3540        assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3541
3542        let fixture = &undeclared[0];
3543        assert_eq!(fixture.name, "my_fixture");
3544        assert_eq!(fixture.function_name, "test_example");
3545        assert_eq!(fixture.line, 3); // Line 3: "result = my_fixture.get()"
3546    }
3547
3548    #[test]
3549    fn test_undeclared_fixture_detection_in_fixture() {
3550        let db = FixtureDatabase::new();
3551
3552        // Add fixture definitions in conftest
3553        let conftest_content = r#"
3554import pytest
3555
3556@pytest.fixture
3557def base_fixture():
3558    return "base"
3559
3560@pytest.fixture
3561def helper_fixture():
3562    return "helper"
3563"#;
3564        let conftest_path = PathBuf::from("/tmp/conftest.py");
3565        db.analyze_file(conftest_path.clone(), conftest_content);
3566
3567        // Add a fixture that uses another fixture without declaring it
3568        let test_content = r#"
3569import pytest
3570
3571@pytest.fixture
3572def my_fixture(base_fixture):
3573    data = helper_fixture.value
3574    return f"{base_fixture}-{data}"
3575"#;
3576        let test_path = PathBuf::from("/tmp/test_example.py");
3577        db.analyze_file(test_path.clone(), test_content);
3578
3579        // Check that undeclared fixture was detected
3580        let undeclared = db.get_undeclared_fixtures(&test_path);
3581        assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3582
3583        let fixture = &undeclared[0];
3584        assert_eq!(fixture.name, "helper_fixture");
3585        assert_eq!(fixture.function_name, "my_fixture");
3586        assert_eq!(fixture.line, 6); // Line 6: "data = helper_fixture.value"
3587    }
3588
3589    #[test]
3590    fn test_no_false_positive_for_declared_fixtures() {
3591        let db = FixtureDatabase::new();
3592
3593        // Add a fixture definition in conftest
3594        let conftest_content = r#"
3595import pytest
3596
3597@pytest.fixture
3598def my_fixture():
3599    return 42
3600"#;
3601        let conftest_path = PathBuf::from("/tmp/conftest.py");
3602        db.analyze_file(conftest_path.clone(), conftest_content);
3603
3604        // Add a test that properly declares the fixture as a parameter
3605        let test_content = r#"
3606def test_example(my_fixture):
3607    result = my_fixture
3608    assert result == 42
3609"#;
3610        let test_path = PathBuf::from("/tmp/test_example.py");
3611        db.analyze_file(test_path.clone(), test_content);
3612
3613        // Check that no undeclared fixtures were detected
3614        let undeclared = db.get_undeclared_fixtures(&test_path);
3615        assert_eq!(
3616            undeclared.len(),
3617            0,
3618            "Should not detect any undeclared fixtures"
3619        );
3620    }
3621
3622    #[test]
3623    fn test_no_false_positive_for_non_fixtures() {
3624        let db = FixtureDatabase::new();
3625
3626        // Add a test that uses regular variables (not fixtures)
3627        let test_content = r#"
3628def test_example():
3629    my_variable = 42
3630    result = my_variable + 10
3631    assert result == 52
3632"#;
3633        let test_path = PathBuf::from("/tmp/test_example.py");
3634        db.analyze_file(test_path.clone(), test_content);
3635
3636        // Check that no undeclared fixtures were detected
3637        let undeclared = db.get_undeclared_fixtures(&test_path);
3638        assert_eq!(
3639            undeclared.len(),
3640            0,
3641            "Should not detect any undeclared fixtures"
3642        );
3643    }
3644
3645    #[test]
3646    fn test_undeclared_fixture_not_available_in_hierarchy() {
3647        let db = FixtureDatabase::new();
3648
3649        // Add a fixture in a different directory (not in hierarchy)
3650        let other_conftest = r#"
3651import pytest
3652
3653@pytest.fixture
3654def other_fixture():
3655    return "other"
3656"#;
3657        let other_path = PathBuf::from("/other/conftest.py");
3658        db.analyze_file(other_path.clone(), other_conftest);
3659
3660        // Add a test that uses a name that happens to match a fixture but isn't available
3661        let test_content = r#"
3662def test_example():
3663    result = other_fixture.value
3664    assert result == "other"
3665"#;
3666        let test_path = PathBuf::from("/tmp/test_example.py");
3667        db.analyze_file(test_path.clone(), test_content);
3668
3669        // Should not detect undeclared fixture because it's not in the hierarchy
3670        let undeclared = db.get_undeclared_fixtures(&test_path);
3671        assert_eq!(
3672            undeclared.len(),
3673            0,
3674            "Should not detect fixtures not in hierarchy"
3675        );
3676    }
3677}
3678
3679#[test]
3680fn test_undeclared_fixture_in_async_test() {
3681    let db = FixtureDatabase::new();
3682
3683    // Add fixture in same file
3684    let content = r#"
3685import pytest
3686
3687@pytest.fixture
3688def http_client():
3689    return "MockClient"
3690
3691async def test_with_undeclared():
3692    response = await http_client.query("test")
3693    assert response == "test"
3694"#;
3695    let test_path = PathBuf::from("/tmp/test_example.py");
3696    db.analyze_file(test_path.clone(), content);
3697
3698    // Check that undeclared fixture was detected
3699    let undeclared = db.get_undeclared_fixtures(&test_path);
3700
3701    println!("Found {} undeclared fixtures", undeclared.len());
3702    for u in &undeclared {
3703        println!("  - {} at line {} in {}", u.name, u.line, u.function_name);
3704    }
3705
3706    assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3707    assert_eq!(undeclared[0].name, "http_client");
3708    assert_eq!(undeclared[0].function_name, "test_with_undeclared");
3709    assert_eq!(undeclared[0].line, 9);
3710}
3711
3712#[test]
3713fn test_undeclared_fixture_in_assert_statement() {
3714    let db = FixtureDatabase::new();
3715
3716    // Add fixture in conftest
3717    let conftest_content = r#"
3718import pytest
3719
3720@pytest.fixture
3721def expected_value():
3722    return 42
3723"#;
3724    let conftest_path = PathBuf::from("/tmp/conftest.py");
3725    db.analyze_file(conftest_path.clone(), conftest_content);
3726
3727    // Test file that uses fixture in assert without declaring it
3728    let test_content = r#"
3729def test_assertion():
3730    result = calculate_value()
3731    assert result == expected_value
3732"#;
3733    let test_path = PathBuf::from("/tmp/test_example.py");
3734    db.analyze_file(test_path.clone(), test_content);
3735
3736    // Check that undeclared fixture was detected in assert
3737    let undeclared = db.get_undeclared_fixtures(&test_path);
3738
3739    assert_eq!(
3740        undeclared.len(),
3741        1,
3742        "Should detect one undeclared fixture in assert"
3743    );
3744    assert_eq!(undeclared[0].name, "expected_value");
3745    assert_eq!(undeclared[0].function_name, "test_assertion");
3746}
3747
3748#[test]
3749fn test_no_false_positive_for_local_variable() {
3750    // Problem 2: Should not warn if a local variable shadows a fixture name
3751    let db = FixtureDatabase::new();
3752
3753    // Add fixture in conftest
3754    let conftest_content = r#"
3755import pytest
3756
3757@pytest.fixture
3758def foo():
3759    return "fixture"
3760"#;
3761    let conftest_path = PathBuf::from("/tmp/conftest.py");
3762    db.analyze_file(conftest_path.clone(), conftest_content);
3763
3764    // Test file that has a local variable with the same name
3765    let test_content = r#"
3766def test_with_local_variable():
3767    foo = "local variable"
3768    result = foo.upper()
3769    assert result == "LOCAL VARIABLE"
3770"#;
3771    let test_path = PathBuf::from("/tmp/test_example.py");
3772    db.analyze_file(test_path.clone(), test_content);
3773
3774    // Should NOT detect undeclared fixture because foo is a local variable
3775    let undeclared = db.get_undeclared_fixtures(&test_path);
3776
3777    assert_eq!(
3778        undeclared.len(),
3779        0,
3780        "Should not detect undeclared fixture when name is a local variable"
3781    );
3782}
3783
3784#[test]
3785fn test_no_false_positive_for_imported_name() {
3786    // Problem 2: Should not warn if an imported name shadows a fixture name
3787    let db = FixtureDatabase::new();
3788
3789    // Add fixture in conftest
3790    let conftest_content = r#"
3791import pytest
3792
3793@pytest.fixture
3794def foo():
3795    return "fixture"
3796"#;
3797    let conftest_path = PathBuf::from("/tmp/conftest.py");
3798    db.analyze_file(conftest_path.clone(), conftest_content);
3799
3800    // Test file that imports a name
3801    let test_content = r#"
3802from mymodule import foo
3803
3804def test_with_import():
3805    result = foo.something()
3806    assert result == "value"
3807"#;
3808    let test_path = PathBuf::from("/tmp/test_example.py");
3809    db.analyze_file(test_path.clone(), test_content);
3810
3811    // Should NOT detect undeclared fixture because foo is imported
3812    let undeclared = db.get_undeclared_fixtures(&test_path);
3813
3814    assert_eq!(
3815        undeclared.len(),
3816        0,
3817        "Should not detect undeclared fixture when name is imported"
3818    );
3819}
3820
3821#[test]
3822fn test_warn_for_fixture_used_directly() {
3823    // Problem 2: SHOULD warn if trying to use a fixture defined in the same file
3824    // This is an error because fixtures must be accessed through parameters
3825    let db = FixtureDatabase::new();
3826
3827    let test_content = r#"
3828import pytest
3829
3830@pytest.fixture
3831def foo():
3832    return "fixture"
3833
3834def test_using_fixture_directly():
3835    # This is an error - fixtures must be declared as parameters
3836    result = foo.something()
3837    assert result == "value"
3838"#;
3839    let test_path = PathBuf::from("/tmp/test_example.py");
3840    db.analyze_file(test_path.clone(), test_content);
3841
3842    // SHOULD detect undeclared fixture even though foo is defined in same file
3843    let undeclared = db.get_undeclared_fixtures(&test_path);
3844
3845    assert_eq!(
3846        undeclared.len(),
3847        1,
3848        "Should detect fixture used directly without parameter declaration"
3849    );
3850    assert_eq!(undeclared[0].name, "foo");
3851    assert_eq!(undeclared[0].function_name, "test_using_fixture_directly");
3852}
3853
3854#[test]
3855fn test_no_false_positive_for_module_level_assignment() {
3856    // Should not warn if name is assigned at module level (not a fixture)
3857    let db = FixtureDatabase::new();
3858
3859    // Add fixture in conftest
3860    let conftest_content = r#"
3861import pytest
3862
3863@pytest.fixture
3864def foo():
3865    return "fixture"
3866"#;
3867    let conftest_path = PathBuf::from("/tmp/conftest.py");
3868    db.analyze_file(conftest_path.clone(), conftest_content);
3869
3870    // Test file that has a module-level assignment
3871    let test_content = r#"
3872# Module-level assignment
3873foo = SomeClass()
3874
3875def test_with_module_var():
3876    result = foo.method()
3877    assert result == "value"
3878"#;
3879    let test_path = PathBuf::from("/tmp/test_example.py");
3880    db.analyze_file(test_path.clone(), test_content);
3881
3882    // Should NOT detect undeclared fixture because foo is assigned at module level
3883    let undeclared = db.get_undeclared_fixtures(&test_path);
3884
3885    assert_eq!(
3886        undeclared.len(),
3887        0,
3888        "Should not detect undeclared fixture when name is assigned at module level"
3889    );
3890}
3891
3892#[test]
3893fn test_no_false_positive_for_function_definition() {
3894    // Should not warn if name is a regular function (not a fixture)
3895    let db = FixtureDatabase::new();
3896
3897    // Add fixture in conftest
3898    let conftest_content = r#"
3899import pytest
3900
3901@pytest.fixture
3902def foo():
3903    return "fixture"
3904"#;
3905    let conftest_path = PathBuf::from("/tmp/conftest.py");
3906    db.analyze_file(conftest_path.clone(), conftest_content);
3907
3908    // Test file that has a regular function with the same name
3909    let test_content = r#"
3910def foo():
3911    return "not a fixture"
3912
3913def test_with_function():
3914    result = foo()
3915    assert result == "not a fixture"
3916"#;
3917    let test_path = PathBuf::from("/tmp/test_example.py");
3918    db.analyze_file(test_path.clone(), test_content);
3919
3920    // Should NOT detect undeclared fixture because foo is a regular function
3921    let undeclared = db.get_undeclared_fixtures(&test_path);
3922
3923    assert_eq!(
3924        undeclared.len(),
3925        0,
3926        "Should not detect undeclared fixture when name is a regular function"
3927    );
3928}
3929
3930#[test]
3931fn test_no_false_positive_for_class_definition() {
3932    // Should not warn if name is a class
3933    let db = FixtureDatabase::new();
3934
3935    // Add fixture in conftest
3936    let conftest_content = r#"
3937import pytest
3938
3939@pytest.fixture
3940def MyClass():
3941    return "fixture"
3942"#;
3943    let conftest_path = PathBuf::from("/tmp/conftest.py");
3944    db.analyze_file(conftest_path.clone(), conftest_content);
3945
3946    // Test file that has a class with the same name
3947    let test_content = r#"
3948class MyClass:
3949    pass
3950
3951def test_with_class():
3952    obj = MyClass()
3953    assert obj is not None
3954"#;
3955    let test_path = PathBuf::from("/tmp/test_example.py");
3956    db.analyze_file(test_path.clone(), test_content);
3957
3958    // Should NOT detect undeclared fixture because MyClass is a class
3959    let undeclared = db.get_undeclared_fixtures(&test_path);
3960
3961    assert_eq!(
3962        undeclared.len(),
3963        0,
3964        "Should not detect undeclared fixture when name is a class"
3965    );
3966}
3967
3968#[test]
3969fn test_line_aware_local_variable_scope() {
3970    // Test that local variables are only considered "in scope" AFTER they're assigned
3971    let db = FixtureDatabase::new();
3972
3973    // Conftest with http_client fixture
3974    let conftest_content = r#"
3975import pytest
3976
3977@pytest.fixture
3978def http_client():
3979    return "MockClient"
3980"#;
3981    let conftest_path = PathBuf::from("/tmp/conftest.py");
3982    db.analyze_file(conftest_path.clone(), conftest_content);
3983
3984    // Test file that uses http_client before and after a local assignment
3985    let test_content = r#"async def test_example():
3986    # Line 1: http_client should be flagged (not yet assigned)
3987    result = await http_client.get("/api")
3988    # Line 3: Now we assign http_client locally
3989    http_client = "local"
3990    # Line 5: http_client should NOT be flagged (local var now)
3991    result2 = await http_client.get("/api2")
3992"#;
3993    let test_path = PathBuf::from("/tmp/test_example.py");
3994    db.analyze_file(test_path.clone(), test_content);
3995
3996    // Check for undeclared fixtures
3997    let undeclared = db.get_undeclared_fixtures(&test_path);
3998
3999    // Should only detect http_client on line 3 (usage before assignment)
4000    // NOT on line 7 (after assignment on line 5)
4001    assert_eq!(
4002        undeclared.len(),
4003        1,
4004        "Should detect http_client only before local assignment"
4005    );
4006    assert_eq!(undeclared[0].name, "http_client");
4007    // Line numbers: 1=def, 2=comment, 3=result (first usage), 4=comment, 5=assignment, 6=comment, 7=result2
4008    assert_eq!(
4009        undeclared[0].line, 3,
4010        "Should flag usage on line 3 (before assignment on line 5)"
4011    );
4012}
4013
4014#[test]
4015fn test_same_line_assignment_and_usage() {
4016    // Test that usage on the same line as assignment refers to the fixture
4017    let db = FixtureDatabase::new();
4018
4019    let conftest_content = r#"import pytest
4020
4021@pytest.fixture
4022def http_client():
4023    return "parent"
4024"#;
4025    let conftest_path = PathBuf::from("/tmp/conftest.py");
4026    db.analyze_file(conftest_path.clone(), conftest_content);
4027
4028    let test_content = r#"async def test_example():
4029    # This references the fixture on the RHS, then assigns to local var
4030    http_client = await http_client.get("/api")
4031"#;
4032    let test_path = PathBuf::from("/tmp/test_example.py");
4033    db.analyze_file(test_path.clone(), test_content);
4034
4035    let undeclared = db.get_undeclared_fixtures(&test_path);
4036
4037    // Should detect http_client on RHS (line 3) because assignment hasn't happened yet
4038    assert_eq!(undeclared.len(), 1);
4039    assert_eq!(undeclared[0].name, "http_client");
4040    assert_eq!(undeclared[0].line, 3);
4041}
4042
4043#[test]
4044fn test_no_false_positive_for_later_assignment() {
4045    // This is the actual bug we fixed - make sure local assignment later in function
4046    // doesn't prevent detection of undeclared fixture usage BEFORE the assignment
4047    let db = FixtureDatabase::new();
4048
4049    let conftest_content = r#"import pytest
4050
4051@pytest.fixture
4052def http_client():
4053    return "fixture"
4054"#;
4055    let conftest_path = PathBuf::from("/tmp/conftest.py");
4056    db.analyze_file(conftest_path.clone(), conftest_content);
4057
4058    // This was the original issue: http_client used on line 2, but assigned on line 4
4059    // Old code would see the assignment and not flag line 2
4060    let test_content = r#"async def test_example():
4061    result = await http_client.get("/api")  # Should be flagged
4062    # Now assign locally
4063    http_client = "local"
4064    # This should NOT be flagged because variable is now assigned
4065    result2 = http_client
4066"#;
4067    let test_path = PathBuf::from("/tmp/test_example.py");
4068    db.analyze_file(test_path.clone(), test_content);
4069
4070    let undeclared = db.get_undeclared_fixtures(&test_path);
4071
4072    // Should only detect one undeclared usage (line 2)
4073    assert_eq!(
4074        undeclared.len(),
4075        1,
4076        "Should detect exactly one undeclared fixture"
4077    );
4078    assert_eq!(undeclared[0].name, "http_client");
4079    assert_eq!(
4080        undeclared[0].line, 2,
4081        "Should flag usage on line 2 before assignment on line 4"
4082    );
4083}
4084
4085#[test]
4086fn test_fixture_resolution_priority_deterministic() {
4087    // Test that fixture resolution is deterministic and follows priority rules
4088    // This test ensures we don't randomly pick a definition from DashMap iteration
4089    let db = FixtureDatabase::new();
4090
4091    // Create multiple conftest.py files with the same fixture name in different locations
4092    // Scenario: /tmp/project/app/tests/test_foo.py should resolve to closest conftest
4093
4094    // Root conftest
4095    let root_content = r#"
4096import pytest
4097
4098@pytest.fixture
4099def db():
4100    return "root_db"
4101"#;
4102    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4103    db.analyze_file(root_conftest.clone(), root_content);
4104
4105    // Unrelated conftest (different branch of directory tree)
4106    let unrelated_content = r#"
4107import pytest
4108
4109@pytest.fixture
4110def db():
4111    return "unrelated_db"
4112"#;
4113    let unrelated_conftest = PathBuf::from("/tmp/other/conftest.py");
4114    db.analyze_file(unrelated_conftest.clone(), unrelated_content);
4115
4116    // App-level conftest
4117    let app_content = r#"
4118import pytest
4119
4120@pytest.fixture
4121def db():
4122    return "app_db"
4123"#;
4124    let app_conftest = PathBuf::from("/tmp/project/app/conftest.py");
4125    db.analyze_file(app_conftest.clone(), app_content);
4126
4127    // Tests-level conftest (closest)
4128    let tests_content = r#"
4129import pytest
4130
4131@pytest.fixture
4132def db():
4133    return "tests_db"
4134"#;
4135    let tests_conftest = PathBuf::from("/tmp/project/app/tests/conftest.py");
4136    db.analyze_file(tests_conftest.clone(), tests_content);
4137
4138    // Test file
4139    let test_content = r#"
4140def test_database(db):
4141    assert db is not None
4142"#;
4143    let test_path = PathBuf::from("/tmp/project/app/tests/test_foo.py");
4144    db.analyze_file(test_path.clone(), test_content);
4145
4146    // Run the resolution multiple times to ensure it's deterministic
4147    for iteration in 0..10 {
4148        let result = db.find_fixture_definition(&test_path, 1, 18); // Line 2, column 18 = "db" parameter
4149
4150        assert!(
4151            result.is_some(),
4152            "Iteration {}: Should find a fixture definition",
4153            iteration
4154        );
4155
4156        let def = result.unwrap();
4157        assert_eq!(
4158            def.name, "db",
4159            "Iteration {}: Should find 'db' fixture",
4160            iteration
4161        );
4162
4163        // Should ALWAYS resolve to the closest conftest.py (tests_conftest)
4164        assert_eq!(
4165            def.file_path, tests_conftest,
4166            "Iteration {}: Should consistently resolve to closest conftest.py at {:?}, but got {:?}",
4167            iteration,
4168            tests_conftest,
4169            def.file_path
4170        );
4171    }
4172}
4173
4174#[test]
4175fn test_fixture_resolution_prefers_parent_over_unrelated() {
4176    // Test that when no fixture is in same file or conftest hierarchy,
4177    // we prefer third-party fixtures (site-packages) over random unrelated conftest files
4178    let db = FixtureDatabase::new();
4179
4180    // Unrelated conftest in different directory tree
4181    let unrelated_content = r#"
4182import pytest
4183
4184@pytest.fixture
4185def custom_fixture():
4186    return "unrelated"
4187"#;
4188    let unrelated_conftest = PathBuf::from("/tmp/other_project/conftest.py");
4189    db.analyze_file(unrelated_conftest.clone(), unrelated_content);
4190
4191    // Third-party fixture (mock in site-packages)
4192    let third_party_content = r#"
4193import pytest
4194
4195@pytest.fixture
4196def custom_fixture():
4197    return "third_party"
4198"#;
4199    let third_party_path =
4200        PathBuf::from("/tmp/.venv/lib/python3.11/site-packages/pytest_custom/plugin.py");
4201    db.analyze_file(third_party_path.clone(), third_party_content);
4202
4203    // Test file in a different project
4204    let test_content = r#"
4205def test_custom(custom_fixture):
4206    assert custom_fixture is not None
4207"#;
4208    let test_path = PathBuf::from("/tmp/my_project/test_foo.py");
4209    db.analyze_file(test_path.clone(), test_content);
4210
4211    // Should prefer third-party fixture over unrelated conftest
4212    let result = db.find_fixture_definition(&test_path, 1, 16);
4213    assert!(result.is_some());
4214    let def = result.unwrap();
4215
4216    // Should be the third-party fixture (site-packages)
4217    assert_eq!(
4218        def.file_path, third_party_path,
4219        "Should prefer third-party fixture from site-packages over unrelated conftest.py"
4220    );
4221}
4222
4223#[test]
4224fn test_fixture_resolution_hierarchy_over_third_party() {
4225    // Test that fixtures in the conftest hierarchy are preferred over third-party
4226    let db = FixtureDatabase::new();
4227
4228    // Third-party fixture
4229    let third_party_content = r#"
4230import pytest
4231
4232@pytest.fixture
4233def mocker():
4234    return "third_party_mocker"
4235"#;
4236    let third_party_path =
4237        PathBuf::from("/tmp/project/.venv/lib/python3.11/site-packages/pytest_mock/plugin.py");
4238    db.analyze_file(third_party_path.clone(), third_party_content);
4239
4240    // Local conftest.py that overrides mocker
4241    let local_content = r#"
4242import pytest
4243
4244@pytest.fixture
4245def mocker():
4246    return "local_mocker"
4247"#;
4248    let local_conftest = PathBuf::from("/tmp/project/conftest.py");
4249    db.analyze_file(local_conftest.clone(), local_content);
4250
4251    // Test file
4252    let test_content = r#"
4253def test_mocking(mocker):
4254    assert mocker is not None
4255"#;
4256    let test_path = PathBuf::from("/tmp/project/test_foo.py");
4257    db.analyze_file(test_path.clone(), test_content);
4258
4259    // Should prefer local conftest over third-party
4260    let result = db.find_fixture_definition(&test_path, 1, 17);
4261    assert!(result.is_some());
4262    let def = result.unwrap();
4263
4264    assert_eq!(
4265        def.file_path, local_conftest,
4266        "Should prefer local conftest.py fixture over third-party fixture"
4267    );
4268}
4269
4270#[test]
4271fn test_fixture_resolution_with_relative_paths() {
4272    // Test that fixture resolution works even when paths are stored with different representations
4273    // This simulates the case where analyze_file is called with relative paths vs absolute paths
4274    let db = FixtureDatabase::new();
4275
4276    // Conftest with absolute path
4277    let conftest_content = r#"
4278import pytest
4279
4280@pytest.fixture
4281def shared():
4282    return "conftest"
4283"#;
4284    let conftest_abs = PathBuf::from("/tmp/project/tests/conftest.py");
4285    db.analyze_file(conftest_abs.clone(), conftest_content);
4286
4287    // Test file also with absolute path
4288    let test_content = r#"
4289def test_example(shared):
4290    assert shared == "conftest"
4291"#;
4292    let test_abs = PathBuf::from("/tmp/project/tests/test_foo.py");
4293    db.analyze_file(test_abs.clone(), test_content);
4294
4295    // Should find the fixture from conftest
4296    let result = db.find_fixture_definition(&test_abs, 1, 17);
4297    assert!(result.is_some(), "Should find fixture with absolute paths");
4298    let def = result.unwrap();
4299    assert_eq!(def.file_path, conftest_abs, "Should resolve to conftest.py");
4300}
4301
4302#[test]
4303fn test_fixture_resolution_deep_hierarchy() {
4304    // Test resolution in a deep directory hierarchy to ensure path traversal works correctly
4305    let db = FixtureDatabase::new();
4306
4307    // Root level fixture
4308    let root_content = r#"
4309import pytest
4310
4311@pytest.fixture
4312def db():
4313    return "root"
4314"#;
4315    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4316    db.analyze_file(root_conftest.clone(), root_content);
4317
4318    // Level 1
4319    let level1_content = r#"
4320import pytest
4321
4322@pytest.fixture
4323def db():
4324    return "level1"
4325"#;
4326    let level1_conftest = PathBuf::from("/tmp/project/src/conftest.py");
4327    db.analyze_file(level1_conftest.clone(), level1_content);
4328
4329    // Level 2
4330    let level2_content = r#"
4331import pytest
4332
4333@pytest.fixture
4334def db():
4335    return "level2"
4336"#;
4337    let level2_conftest = PathBuf::from("/tmp/project/src/app/conftest.py");
4338    db.analyze_file(level2_conftest.clone(), level2_content);
4339
4340    // Level 3 - deepest
4341    let level3_content = r#"
4342import pytest
4343
4344@pytest.fixture
4345def db():
4346    return "level3"
4347"#;
4348    let level3_conftest = PathBuf::from("/tmp/project/src/app/tests/conftest.py");
4349    db.analyze_file(level3_conftest.clone(), level3_content);
4350
4351    // Test at level 3 - should use level 3 fixture
4352    let test_l3_content = r#"
4353def test_db(db):
4354    assert db == "level3"
4355"#;
4356    let test_l3 = PathBuf::from("/tmp/project/src/app/tests/test_foo.py");
4357    db.analyze_file(test_l3.clone(), test_l3_content);
4358
4359    let result_l3 = db.find_fixture_definition(&test_l3, 1, 12);
4360    assert!(result_l3.is_some());
4361    assert_eq!(
4362        result_l3.unwrap().file_path,
4363        level3_conftest,
4364        "Test at level 3 should use level 3 fixture"
4365    );
4366
4367    // Test at level 2 - should use level 2 fixture
4368    let test_l2_content = r#"
4369def test_db(db):
4370    assert db == "level2"
4371"#;
4372    let test_l2 = PathBuf::from("/tmp/project/src/app/test_bar.py");
4373    db.analyze_file(test_l2.clone(), test_l2_content);
4374
4375    let result_l2 = db.find_fixture_definition(&test_l2, 1, 12);
4376    assert!(result_l2.is_some());
4377    assert_eq!(
4378        result_l2.unwrap().file_path,
4379        level2_conftest,
4380        "Test at level 2 should use level 2 fixture"
4381    );
4382
4383    // Test at level 1 - should use level 1 fixture
4384    let test_l1_content = r#"
4385def test_db(db):
4386    assert db == "level1"
4387"#;
4388    let test_l1 = PathBuf::from("/tmp/project/src/test_baz.py");
4389    db.analyze_file(test_l1.clone(), test_l1_content);
4390
4391    let result_l1 = db.find_fixture_definition(&test_l1, 1, 12);
4392    assert!(result_l1.is_some());
4393    assert_eq!(
4394        result_l1.unwrap().file_path,
4395        level1_conftest,
4396        "Test at level 1 should use level 1 fixture"
4397    );
4398
4399    // Test at root - should use root fixture
4400    let test_root_content = r#"
4401def test_db(db):
4402    assert db == "root"
4403"#;
4404    let test_root = PathBuf::from("/tmp/project/test_root.py");
4405    db.analyze_file(test_root.clone(), test_root_content);
4406
4407    let result_root = db.find_fixture_definition(&test_root, 1, 12);
4408    assert!(result_root.is_some());
4409    assert_eq!(
4410        result_root.unwrap().file_path,
4411        root_conftest,
4412        "Test at root should use root fixture"
4413    );
4414}
4415
4416#[test]
4417fn test_fixture_resolution_sibling_directories() {
4418    // Test that fixtures in sibling directories don't leak into each other
4419    let db = FixtureDatabase::new();
4420
4421    // Root conftest
4422    let root_content = r#"
4423import pytest
4424
4425@pytest.fixture
4426def shared():
4427    return "root"
4428"#;
4429    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4430    db.analyze_file(root_conftest.clone(), root_content);
4431
4432    // Module A with its own fixture
4433    let module_a_content = r#"
4434import pytest
4435
4436@pytest.fixture
4437def module_specific():
4438    return "module_a"
4439"#;
4440    let module_a_conftest = PathBuf::from("/tmp/project/module_a/conftest.py");
4441    db.analyze_file(module_a_conftest.clone(), module_a_content);
4442
4443    // Module B with its own fixture (same name!)
4444    let module_b_content = r#"
4445import pytest
4446
4447@pytest.fixture
4448def module_specific():
4449    return "module_b"
4450"#;
4451    let module_b_conftest = PathBuf::from("/tmp/project/module_b/conftest.py");
4452    db.analyze_file(module_b_conftest.clone(), module_b_content);
4453
4454    // Test in module A - should use module A's fixture
4455    let test_a_content = r#"
4456def test_a(module_specific, shared):
4457    assert module_specific == "module_a"
4458    assert shared == "root"
4459"#;
4460    let test_a = PathBuf::from("/tmp/project/module_a/test_a.py");
4461    db.analyze_file(test_a.clone(), test_a_content);
4462
4463    let result_a = db.find_fixture_definition(&test_a, 1, 11);
4464    assert!(result_a.is_some());
4465    assert_eq!(
4466        result_a.unwrap().file_path,
4467        module_a_conftest,
4468        "Test in module_a should use module_a's fixture"
4469    );
4470
4471    // Test in module B - should use module B's fixture
4472    let test_b_content = r#"
4473def test_b(module_specific, shared):
4474    assert module_specific == "module_b"
4475    assert shared == "root"
4476"#;
4477    let test_b = PathBuf::from("/tmp/project/module_b/test_b.py");
4478    db.analyze_file(test_b.clone(), test_b_content);
4479
4480    let result_b = db.find_fixture_definition(&test_b, 1, 11);
4481    assert!(result_b.is_some());
4482    assert_eq!(
4483        result_b.unwrap().file_path,
4484        module_b_conftest,
4485        "Test in module_b should use module_b's fixture"
4486    );
4487
4488    // Both should be able to access shared root fixture
4489    // "shared" starts at column 29 (after "module_specific, ")
4490    let result_a_shared = db.find_fixture_definition(&test_a, 1, 29);
4491    assert!(result_a_shared.is_some());
4492    assert_eq!(
4493        result_a_shared.unwrap().file_path,
4494        root_conftest,
4495        "Test in module_a should access root's shared fixture"
4496    );
4497
4498    let result_b_shared = db.find_fixture_definition(&test_b, 1, 29);
4499    assert!(result_b_shared.is_some());
4500    assert_eq!(
4501        result_b_shared.unwrap().file_path,
4502        root_conftest,
4503        "Test in module_b should access root's shared fixture"
4504    );
4505}
4506
4507#[test]
4508fn test_fixture_resolution_multiple_unrelated_branches_is_deterministic() {
4509    // This is the key test: when a fixture is defined in multiple unrelated branches,
4510    // the resolution should be deterministic (not random based on DashMap iteration)
4511    let db = FixtureDatabase::new();
4512
4513    // Three unrelated project branches
4514    let branch_a_content = r#"
4515import pytest
4516
4517@pytest.fixture
4518def common_fixture():
4519    return "branch_a"
4520"#;
4521    let branch_a_conftest = PathBuf::from("/tmp/projects/project_a/conftest.py");
4522    db.analyze_file(branch_a_conftest.clone(), branch_a_content);
4523
4524    let branch_b_content = r#"
4525import pytest
4526
4527@pytest.fixture
4528def common_fixture():
4529    return "branch_b"
4530"#;
4531    let branch_b_conftest = PathBuf::from("/tmp/projects/project_b/conftest.py");
4532    db.analyze_file(branch_b_conftest.clone(), branch_b_content);
4533
4534    let branch_c_content = r#"
4535import pytest
4536
4537@pytest.fixture
4538def common_fixture():
4539    return "branch_c"
4540"#;
4541    let branch_c_conftest = PathBuf::from("/tmp/projects/project_c/conftest.py");
4542    db.analyze_file(branch_c_conftest.clone(), branch_c_content);
4543
4544    // Test in yet another unrelated location
4545    let test_content = r#"
4546def test_something(common_fixture):
4547    assert common_fixture is not None
4548"#;
4549    let test_path = PathBuf::from("/tmp/unrelated/test_foo.py");
4550    db.analyze_file(test_path.clone(), test_content);
4551
4552    // Run resolution multiple times - should always return the same result
4553    let mut results = Vec::new();
4554    for _ in 0..20 {
4555        let result = db.find_fixture_definition(&test_path, 1, 19);
4556        assert!(result.is_some(), "Should find a fixture");
4557        results.push(result.unwrap().file_path.clone());
4558    }
4559
4560    // All results should be identical (deterministic)
4561    let first_result = &results[0];
4562    for (i, result) in results.iter().enumerate() {
4563        assert_eq!(
4564            result, first_result,
4565            "Iteration {}: fixture resolution should be deterministic, expected {:?} but got {:?}",
4566            i, first_result, result
4567        );
4568    }
4569}
4570
4571#[test]
4572fn test_fixture_resolution_conftest_at_various_depths() {
4573    // Test that conftest.py files at different depths are correctly prioritized
4574    let db = FixtureDatabase::new();
4575
4576    // Deep conftest
4577    let deep_content = r#"
4578import pytest
4579
4580@pytest.fixture
4581def fixture_a():
4582    return "deep"
4583
4584@pytest.fixture
4585def fixture_b():
4586    return "deep"
4587"#;
4588    let deep_conftest = PathBuf::from("/tmp/project/src/module/tests/integration/conftest.py");
4589    db.analyze_file(deep_conftest.clone(), deep_content);
4590
4591    // Mid-level conftest - overrides fixture_a but not fixture_b
4592    let mid_content = r#"
4593import pytest
4594
4595@pytest.fixture
4596def fixture_a():
4597    return "mid"
4598"#;
4599    let mid_conftest = PathBuf::from("/tmp/project/src/module/conftest.py");
4600    db.analyze_file(mid_conftest.clone(), mid_content);
4601
4602    // Root conftest - defines fixture_c
4603    let root_content = r#"
4604import pytest
4605
4606@pytest.fixture
4607def fixture_c():
4608    return "root"
4609"#;
4610    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4611    db.analyze_file(root_conftest.clone(), root_content);
4612
4613    // Test in deep directory
4614    let test_content = r#"
4615def test_all(fixture_a, fixture_b, fixture_c):
4616    assert fixture_a == "deep"
4617    assert fixture_b == "deep"
4618    assert fixture_c == "root"
4619"#;
4620    let test_path = PathBuf::from("/tmp/project/src/module/tests/integration/test_foo.py");
4621    db.analyze_file(test_path.clone(), test_content);
4622
4623    // fixture_a: should resolve to deep (closest)
4624    let result_a = db.find_fixture_definition(&test_path, 1, 13);
4625    assert!(result_a.is_some());
4626    assert_eq!(
4627        result_a.unwrap().file_path,
4628        deep_conftest,
4629        "fixture_a should resolve to closest conftest (deep)"
4630    );
4631
4632    // fixture_b: should resolve to deep (only defined there)
4633    let result_b = db.find_fixture_definition(&test_path, 1, 24);
4634    assert!(result_b.is_some());
4635    assert_eq!(
4636        result_b.unwrap().file_path,
4637        deep_conftest,
4638        "fixture_b should resolve to deep conftest"
4639    );
4640
4641    // fixture_c: should resolve to root (only defined there)
4642    let result_c = db.find_fixture_definition(&test_path, 1, 35);
4643    assert!(result_c.is_some());
4644    assert_eq!(
4645        result_c.unwrap().file_path,
4646        root_conftest,
4647        "fixture_c should resolve to root conftest"
4648    );
4649
4650    // Test in mid-level directory (one level up)
4651    let test_mid_content = r#"
4652def test_mid(fixture_a, fixture_c):
4653    assert fixture_a == "mid"
4654    assert fixture_c == "root"
4655"#;
4656    let test_mid_path = PathBuf::from("/tmp/project/src/module/test_bar.py");
4657    db.analyze_file(test_mid_path.clone(), test_mid_content);
4658
4659    // fixture_a from mid-level: should resolve to mid conftest
4660    let result_a_mid = db.find_fixture_definition(&test_mid_path, 1, 13);
4661    assert!(result_a_mid.is_some());
4662    assert_eq!(
4663        result_a_mid.unwrap().file_path,
4664        mid_conftest,
4665        "fixture_a from mid-level test should resolve to mid conftest"
4666    );
4667}
4668
4669#[test]
4670fn test_get_available_fixtures_same_file() {
4671    let db = FixtureDatabase::new();
4672
4673    let test_content = r#"
4674import pytest
4675
4676@pytest.fixture
4677def fixture_a():
4678    return "a"
4679
4680@pytest.fixture
4681def fixture_b():
4682    return "b"
4683
4684def test_something():
4685    pass
4686"#;
4687    let test_path = PathBuf::from("/tmp/test/test_example.py");
4688    db.analyze_file(test_path.clone(), test_content);
4689
4690    let available = db.get_available_fixtures(&test_path);
4691
4692    assert_eq!(available.len(), 2, "Should find 2 fixtures in same file");
4693
4694    let names: Vec<_> = available.iter().map(|f| f.name.as_str()).collect();
4695    assert!(names.contains(&"fixture_a"));
4696    assert!(names.contains(&"fixture_b"));
4697}
4698
4699#[test]
4700fn test_get_available_fixtures_conftest_hierarchy() {
4701    let db = FixtureDatabase::new();
4702
4703    // Root conftest
4704    let root_conftest = r#"
4705import pytest
4706
4707@pytest.fixture
4708def root_fixture():
4709    return "root"
4710"#;
4711    let root_path = PathBuf::from("/tmp/test/conftest.py");
4712    db.analyze_file(root_path.clone(), root_conftest);
4713
4714    // Subdir conftest
4715    let sub_conftest = r#"
4716import pytest
4717
4718@pytest.fixture
4719def sub_fixture():
4720    return "sub"
4721"#;
4722    let sub_path = PathBuf::from("/tmp/test/subdir/conftest.py");
4723    db.analyze_file(sub_path.clone(), sub_conftest);
4724
4725    // Test file in subdir
4726    let test_content = r#"
4727def test_something():
4728    pass
4729"#;
4730    let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
4731    db.analyze_file(test_path.clone(), test_content);
4732
4733    let available = db.get_available_fixtures(&test_path);
4734
4735    assert_eq!(
4736        available.len(),
4737        2,
4738        "Should find fixtures from both conftest files"
4739    );
4740
4741    let names: Vec<_> = available.iter().map(|f| f.name.as_str()).collect();
4742    assert!(names.contains(&"root_fixture"));
4743    assert!(names.contains(&"sub_fixture"));
4744}
4745
4746#[test]
4747fn test_get_available_fixtures_no_duplicates() {
4748    let db = FixtureDatabase::new();
4749
4750    // Root conftest
4751    let root_conftest = r#"
4752import pytest
4753
4754@pytest.fixture
4755def shared_fixture():
4756    return "root"
4757"#;
4758    let root_path = PathBuf::from("/tmp/test/conftest.py");
4759    db.analyze_file(root_path.clone(), root_conftest);
4760
4761    // Subdir conftest with same fixture name
4762    let sub_conftest = r#"
4763import pytest
4764
4765@pytest.fixture
4766def shared_fixture():
4767    return "sub"
4768"#;
4769    let sub_path = PathBuf::from("/tmp/test/subdir/conftest.py");
4770    db.analyze_file(sub_path.clone(), sub_conftest);
4771
4772    // Test file in subdir
4773    let test_content = r#"
4774def test_something():
4775    pass
4776"#;
4777    let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
4778    db.analyze_file(test_path.clone(), test_content);
4779
4780    let available = db.get_available_fixtures(&test_path);
4781
4782    // Should only find one "shared_fixture" (the closest one)
4783    let shared_count = available
4784        .iter()
4785        .filter(|f| f.name == "shared_fixture")
4786        .count();
4787    assert_eq!(shared_count, 1, "Should only include shared_fixture once");
4788
4789    // The one included should be from the subdir (closest)
4790    let shared_fixture = available
4791        .iter()
4792        .find(|f| f.name == "shared_fixture")
4793        .unwrap();
4794    assert_eq!(shared_fixture.file_path, sub_path);
4795}
4796
4797#[test]
4798fn test_is_inside_function_in_test() {
4799    let db = FixtureDatabase::new();
4800
4801    let test_content = r#"
4802import pytest
4803
4804def test_example(fixture_a, fixture_b):
4805    result = fixture_a + fixture_b
4806    assert result == "ab"
4807"#;
4808    let test_path = PathBuf::from("/tmp/test/test_example.py");
4809    db.analyze_file(test_path.clone(), test_content);
4810
4811    // Test on the function definition line (line 4, 0-indexed line 3)
4812    let result = db.is_inside_function(&test_path, 3, 10);
4813    assert!(result.is_some());
4814
4815    let (func_name, is_fixture, params) = result.unwrap();
4816    assert_eq!(func_name, "test_example");
4817    assert!(!is_fixture);
4818    assert_eq!(params, vec!["fixture_a", "fixture_b"]);
4819
4820    // Test inside the function body (line 5, 0-indexed line 4)
4821    let result = db.is_inside_function(&test_path, 4, 10);
4822    assert!(result.is_some());
4823
4824    let (func_name, is_fixture, _) = result.unwrap();
4825    assert_eq!(func_name, "test_example");
4826    assert!(!is_fixture);
4827}
4828
4829#[test]
4830fn test_is_inside_function_in_fixture() {
4831    let db = FixtureDatabase::new();
4832
4833    let test_content = r#"
4834import pytest
4835
4836@pytest.fixture
4837def my_fixture(other_fixture):
4838    return other_fixture + "_modified"
4839"#;
4840    let test_path = PathBuf::from("/tmp/test/conftest.py");
4841    db.analyze_file(test_path.clone(), test_content);
4842
4843    // Test on the function definition line (line 5, 0-indexed line 4)
4844    let result = db.is_inside_function(&test_path, 4, 10);
4845    assert!(result.is_some());
4846
4847    let (func_name, is_fixture, params) = result.unwrap();
4848    assert_eq!(func_name, "my_fixture");
4849    assert!(is_fixture);
4850    assert_eq!(params, vec!["other_fixture"]);
4851
4852    // Test inside the function body (line 6, 0-indexed line 5)
4853    let result = db.is_inside_function(&test_path, 5, 10);
4854    assert!(result.is_some());
4855
4856    let (func_name, is_fixture, _) = result.unwrap();
4857    assert_eq!(func_name, "my_fixture");
4858    assert!(is_fixture);
4859}
4860
4861#[test]
4862fn test_is_inside_function_outside() {
4863    let db = FixtureDatabase::new();
4864
4865    let test_content = r#"
4866import pytest
4867
4868@pytest.fixture
4869def my_fixture():
4870    return "value"
4871
4872def test_example(my_fixture):
4873    assert my_fixture == "value"
4874
4875# This is a comment outside any function
4876"#;
4877    let test_path = PathBuf::from("/tmp/test/test_example.py");
4878    db.analyze_file(test_path.clone(), test_content);
4879
4880    // Test on the import line (line 1, 0-indexed line 0)
4881    let result = db.is_inside_function(&test_path, 0, 0);
4882    assert!(
4883        result.is_none(),
4884        "Should not be inside a function on import line"
4885    );
4886
4887    // Test on the comment line (line 10, 0-indexed line 9)
4888    let result = db.is_inside_function(&test_path, 9, 0);
4889    assert!(
4890        result.is_none(),
4891        "Should not be inside a function on comment line"
4892    );
4893}
4894
4895#[test]
4896fn test_is_inside_function_non_test() {
4897    let db = FixtureDatabase::new();
4898
4899    let test_content = r#"
4900import pytest
4901
4902def helper_function():
4903    return "helper"
4904
4905def test_example():
4906    result = helper_function()
4907    assert result == "helper"
4908"#;
4909    let test_path = PathBuf::from("/tmp/test/test_example.py");
4910    db.analyze_file(test_path.clone(), test_content);
4911
4912    // Test inside helper_function (not a test or fixture)
4913    let result = db.is_inside_function(&test_path, 3, 10);
4914    assert!(
4915        result.is_none(),
4916        "Should not return non-test, non-fixture functions"
4917    );
4918
4919    // Test inside test_example (is a test)
4920    let result = db.is_inside_function(&test_path, 6, 10);
4921    assert!(result.is_some(), "Should return test functions");
4922
4923    let (func_name, is_fixture, _) = result.unwrap();
4924    assert_eq!(func_name, "test_example");
4925    assert!(!is_fixture);
4926}
4927
4928#[test]
4929fn test_is_inside_async_function() {
4930    let db = FixtureDatabase::new();
4931
4932    let test_content = r#"
4933import pytest
4934
4935@pytest.fixture
4936async def async_fixture():
4937    return "async_value"
4938
4939async def test_async_example(async_fixture):
4940    assert async_fixture == "async_value"
4941"#;
4942    let test_path = PathBuf::from("/tmp/test/test_async.py");
4943    db.analyze_file(test_path.clone(), test_content);
4944
4945    // Test inside async fixture (line 5, 0-indexed line 4)
4946    let result = db.is_inside_function(&test_path, 4, 10);
4947    assert!(result.is_some());
4948
4949    let (func_name, is_fixture, _) = result.unwrap();
4950    assert_eq!(func_name, "async_fixture");
4951    assert!(is_fixture);
4952
4953    // Test inside async test (line 8, 0-indexed line 7)
4954    let result = db.is_inside_function(&test_path, 7, 10);
4955    assert!(result.is_some());
4956
4957    let (func_name, is_fixture, params) = result.unwrap();
4958    assert_eq!(func_name, "test_async_example");
4959    assert!(!is_fixture);
4960    assert_eq!(params, vec!["async_fixture"]);
4961}