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
1940#[cfg(test)]
1941mod tests {
1942    use super::*;
1943    use std::path::PathBuf;
1944
1945    #[test]
1946    fn test_fixture_definition_detection() {
1947        let db = FixtureDatabase::new();
1948
1949        let conftest_content = r#"
1950import pytest
1951
1952@pytest.fixture
1953def my_fixture():
1954    return 42
1955
1956@fixture
1957def another_fixture():
1958    return "hello"
1959"#;
1960
1961        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
1962        db.analyze_file(conftest_path.clone(), conftest_content);
1963
1964        // Check that fixtures were detected
1965        assert!(db.definitions.contains_key("my_fixture"));
1966        assert!(db.definitions.contains_key("another_fixture"));
1967
1968        // Check fixture details
1969        let my_fixture_defs = db.definitions.get("my_fixture").unwrap();
1970        assert_eq!(my_fixture_defs.len(), 1);
1971        assert_eq!(my_fixture_defs[0].name, "my_fixture");
1972        assert_eq!(my_fixture_defs[0].file_path, conftest_path);
1973    }
1974
1975    #[test]
1976    fn test_fixture_usage_detection() {
1977        let db = FixtureDatabase::new();
1978
1979        let test_content = r#"
1980def test_something(my_fixture, another_fixture):
1981    assert my_fixture == 42
1982    assert another_fixture == "hello"
1983
1984def test_other(my_fixture):
1985    assert my_fixture > 0
1986"#;
1987
1988        let test_path = PathBuf::from("/tmp/test/test_example.py");
1989        db.analyze_file(test_path.clone(), test_content);
1990
1991        // Check that usages were detected
1992        assert!(db.usages.contains_key(&test_path));
1993
1994        let usages = db.usages.get(&test_path).unwrap();
1995        // Should have usages from the first test function (we only track one function per file currently)
1996        assert!(usages.iter().any(|u| u.name == "my_fixture"));
1997        assert!(usages.iter().any(|u| u.name == "another_fixture"));
1998    }
1999
2000    #[test]
2001    fn test_go_to_definition() {
2002        let db = FixtureDatabase::new();
2003
2004        // Set up conftest.py with a fixture
2005        let conftest_content = r#"
2006import pytest
2007
2008@pytest.fixture
2009def my_fixture():
2010    return 42
2011"#;
2012
2013        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2014        db.analyze_file(conftest_path.clone(), conftest_content);
2015
2016        // Set up a test file that uses the fixture
2017        let test_content = r#"
2018def test_something(my_fixture):
2019    assert my_fixture == 42
2020"#;
2021
2022        let test_path = PathBuf::from("/tmp/test/test_example.py");
2023        db.analyze_file(test_path.clone(), test_content);
2024
2025        // Try to find the definition from the test file
2026        // The usage is on line 2 (1-indexed) - that's where the function parameter is
2027        // In 0-indexed LSP coordinates, that's line 1
2028        // Character position 19 is where 'my_fixture' starts
2029        let definition = db.find_fixture_definition(&test_path, 1, 19);
2030
2031        assert!(definition.is_some(), "Definition should be found");
2032        let def = definition.unwrap();
2033        assert_eq!(def.name, "my_fixture");
2034        assert_eq!(def.file_path, conftest_path);
2035    }
2036
2037    #[test]
2038    fn test_fixture_decorator_variations() {
2039        let db = FixtureDatabase::new();
2040
2041        let conftest_content = r#"
2042import pytest
2043from pytest import fixture
2044
2045@pytest.fixture
2046def fixture1():
2047    pass
2048
2049@pytest.fixture()
2050def fixture2():
2051    pass
2052
2053@fixture
2054def fixture3():
2055    pass
2056
2057@fixture()
2058def fixture4():
2059    pass
2060"#;
2061
2062        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2063        db.analyze_file(conftest_path, conftest_content);
2064
2065        // Check all variations were detected
2066        assert!(db.definitions.contains_key("fixture1"));
2067        assert!(db.definitions.contains_key("fixture2"));
2068        assert!(db.definitions.contains_key("fixture3"));
2069        assert!(db.definitions.contains_key("fixture4"));
2070    }
2071
2072    #[test]
2073    fn test_fixture_in_test_file() {
2074        let db = FixtureDatabase::new();
2075
2076        // Test file with fixture defined in the same file
2077        let test_content = r#"
2078import pytest
2079
2080@pytest.fixture
2081def local_fixture():
2082    return 42
2083
2084def test_something(local_fixture):
2085    assert local_fixture == 42
2086"#;
2087
2088        let test_path = PathBuf::from("/tmp/test/test_example.py");
2089        db.analyze_file(test_path.clone(), test_content);
2090
2091        // Check that fixture was detected even though it's not in conftest.py
2092        assert!(db.definitions.contains_key("local_fixture"));
2093
2094        let local_fixture_defs = db.definitions.get("local_fixture").unwrap();
2095        assert_eq!(local_fixture_defs.len(), 1);
2096        assert_eq!(local_fixture_defs[0].name, "local_fixture");
2097        assert_eq!(local_fixture_defs[0].file_path, test_path);
2098
2099        // Check that usage was detected
2100        assert!(db.usages.contains_key(&test_path));
2101        let usages = db.usages.get(&test_path).unwrap();
2102        assert!(usages.iter().any(|u| u.name == "local_fixture"));
2103
2104        // Test go-to-definition for fixture in same file
2105        let usage_line = usages
2106            .iter()
2107            .find(|u| u.name == "local_fixture")
2108            .map(|u| u.line)
2109            .unwrap();
2110
2111        // Character position 19 is where 'local_fixture' starts in "def test_something(local_fixture):"
2112        let definition = db.find_fixture_definition(&test_path, (usage_line - 1) as u32, 19);
2113        assert!(
2114            definition.is_some(),
2115            "Should find definition for fixture in same file. Line: {}, char: 19",
2116            usage_line
2117        );
2118        let def = definition.unwrap();
2119        assert_eq!(def.name, "local_fixture");
2120        assert_eq!(def.file_path, test_path);
2121    }
2122
2123    #[test]
2124    fn test_async_test_functions() {
2125        let db = FixtureDatabase::new();
2126
2127        // Test file with async test function
2128        let test_content = r#"
2129import pytest
2130
2131@pytest.fixture
2132def my_fixture():
2133    return 42
2134
2135async def test_async_function(my_fixture):
2136    assert my_fixture == 42
2137
2138def test_sync_function(my_fixture):
2139    assert my_fixture == 42
2140"#;
2141
2142        let test_path = PathBuf::from("/tmp/test/test_async.py");
2143        db.analyze_file(test_path.clone(), test_content);
2144
2145        // Check that fixture was detected
2146        assert!(db.definitions.contains_key("my_fixture"));
2147
2148        // Check that both async and sync test functions have their usages detected
2149        assert!(db.usages.contains_key(&test_path));
2150        let usages = db.usages.get(&test_path).unwrap();
2151
2152        // Should have 2 usages (one from async, one from sync)
2153        let fixture_usages: Vec<_> = usages.iter().filter(|u| u.name == "my_fixture").collect();
2154        assert_eq!(
2155            fixture_usages.len(),
2156            2,
2157            "Should detect fixture usage in both async and sync tests"
2158        );
2159    }
2160
2161    #[test]
2162    fn test_extract_word_at_position() {
2163        let db = FixtureDatabase::new();
2164
2165        // Test basic word extraction
2166        let line = "def test_something(my_fixture):";
2167
2168        // Cursor on 'm' of 'my_fixture' (position 19)
2169        assert_eq!(
2170            db.extract_word_at_position(line, 19),
2171            Some("my_fixture".to_string())
2172        );
2173
2174        // Cursor on 'y' of 'my_fixture' (position 20)
2175        assert_eq!(
2176            db.extract_word_at_position(line, 20),
2177            Some("my_fixture".to_string())
2178        );
2179
2180        // Cursor on last 'e' of 'my_fixture' (position 28)
2181        assert_eq!(
2182            db.extract_word_at_position(line, 28),
2183            Some("my_fixture".to_string())
2184        );
2185
2186        // Cursor on 'd' of 'def' (position 0)
2187        assert_eq!(
2188            db.extract_word_at_position(line, 0),
2189            Some("def".to_string())
2190        );
2191
2192        // Cursor on space after 'def' (position 3) - should return None
2193        assert_eq!(db.extract_word_at_position(line, 3), None);
2194
2195        // Cursor on 't' of 'test_something' (position 4)
2196        assert_eq!(
2197            db.extract_word_at_position(line, 4),
2198            Some("test_something".to_string())
2199        );
2200
2201        // Cursor on opening parenthesis (position 18) - should return None
2202        assert_eq!(db.extract_word_at_position(line, 18), None);
2203
2204        // Cursor on closing parenthesis (position 29) - should return None
2205        assert_eq!(db.extract_word_at_position(line, 29), None);
2206
2207        // Cursor on colon (position 31) - should return None
2208        assert_eq!(db.extract_word_at_position(line, 31), None);
2209    }
2210
2211    #[test]
2212    fn test_extract_word_at_position_fixture_definition() {
2213        let db = FixtureDatabase::new();
2214
2215        let line = "@pytest.fixture";
2216
2217        // Cursor on '@' - should return None
2218        assert_eq!(db.extract_word_at_position(line, 0), None);
2219
2220        // Cursor on 'p' of 'pytest' (position 1)
2221        assert_eq!(
2222            db.extract_word_at_position(line, 1),
2223            Some("pytest".to_string())
2224        );
2225
2226        // Cursor on '.' - should return None
2227        assert_eq!(db.extract_word_at_position(line, 7), None);
2228
2229        // Cursor on 'f' of 'fixture' (position 8)
2230        assert_eq!(
2231            db.extract_word_at_position(line, 8),
2232            Some("fixture".to_string())
2233        );
2234
2235        let line2 = "def foo(other_fixture):";
2236
2237        // Cursor on 'd' of 'def'
2238        assert_eq!(
2239            db.extract_word_at_position(line2, 0),
2240            Some("def".to_string())
2241        );
2242
2243        // Cursor on space after 'def' - should return None
2244        assert_eq!(db.extract_word_at_position(line2, 3), None);
2245
2246        // Cursor on 'f' of 'foo'
2247        assert_eq!(
2248            db.extract_word_at_position(line2, 4),
2249            Some("foo".to_string())
2250        );
2251
2252        // Cursor on 'o' of 'other_fixture'
2253        assert_eq!(
2254            db.extract_word_at_position(line2, 8),
2255            Some("other_fixture".to_string())
2256        );
2257
2258        // Cursor on parenthesis - should return None
2259        assert_eq!(db.extract_word_at_position(line2, 7), None);
2260    }
2261
2262    #[test]
2263    fn test_word_detection_only_on_fixtures() {
2264        let db = FixtureDatabase::new();
2265
2266        // Set up a conftest with a fixture
2267        let conftest_content = r#"
2268import pytest
2269
2270@pytest.fixture
2271def my_fixture():
2272    return 42
2273"#;
2274        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2275        db.analyze_file(conftest_path.clone(), conftest_content);
2276
2277        // Set up a test file
2278        let test_content = r#"
2279def test_something(my_fixture, regular_param):
2280    assert my_fixture == 42
2281"#;
2282        let test_path = PathBuf::from("/tmp/test/test_example.py");
2283        db.analyze_file(test_path.clone(), test_content);
2284
2285        // Line 2 is "def test_something(my_fixture, regular_param):"
2286        // Character positions:
2287        // 0: 'd' of 'def'
2288        // 4: 't' of 'test_something'
2289        // 19: 'm' of 'my_fixture'
2290        // 31: 'r' of 'regular_param'
2291
2292        // Cursor on 'def' - should NOT find a fixture (LSP line 1, 0-based)
2293        assert_eq!(db.find_fixture_definition(&test_path, 1, 0), None);
2294
2295        // Cursor on 'test_something' - should NOT find a fixture
2296        assert_eq!(db.find_fixture_definition(&test_path, 1, 4), None);
2297
2298        // Cursor on 'my_fixture' - SHOULD find the fixture
2299        let result = db.find_fixture_definition(&test_path, 1, 19);
2300        assert!(result.is_some());
2301        let def = result.unwrap();
2302        assert_eq!(def.name, "my_fixture");
2303
2304        // Cursor on 'regular_param' - should NOT find a fixture (it's not a fixture)
2305        assert_eq!(db.find_fixture_definition(&test_path, 1, 31), None);
2306
2307        // Cursor on comma or parenthesis - should NOT find a fixture
2308        assert_eq!(db.find_fixture_definition(&test_path, 1, 18), None); // '('
2309        assert_eq!(db.find_fixture_definition(&test_path, 1, 29), None); // ','
2310    }
2311
2312    #[test]
2313    fn test_self_referencing_fixture() {
2314        let db = FixtureDatabase::new();
2315
2316        // Set up a parent conftest.py with the original fixture
2317        let parent_conftest_content = r#"
2318import pytest
2319
2320@pytest.fixture
2321def foo():
2322    return "parent"
2323"#;
2324        let parent_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2325        db.analyze_file(parent_conftest_path.clone(), parent_conftest_content);
2326
2327        // Set up a child directory conftest.py that overrides foo, referencing itself
2328        let child_conftest_content = r#"
2329import pytest
2330
2331@pytest.fixture
2332def foo(foo):
2333    return foo + " child"
2334"#;
2335        let child_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2336        db.analyze_file(child_conftest_path.clone(), child_conftest_content);
2337
2338        // Now test go-to-definition on the parameter `foo` in the child fixture
2339        // Line 5 is "def foo(foo):" (1-indexed)
2340        // Character position 8 is the 'f' in the parameter name "foo"
2341        // LSP uses 0-indexed lines, so line 4 in LSP coordinates
2342
2343        let result = db.find_fixture_definition(&child_conftest_path, 4, 8);
2344
2345        assert!(
2346            result.is_some(),
2347            "Should find parent definition for self-referencing fixture"
2348        );
2349        let def = result.unwrap();
2350        assert_eq!(def.name, "foo");
2351        assert_eq!(
2352            def.file_path, parent_conftest_path,
2353            "Should resolve to parent conftest.py, not the child"
2354        );
2355        assert_eq!(def.line, 5, "Should point to line 5 of parent conftest.py");
2356    }
2357
2358    #[test]
2359    fn test_fixture_overriding_same_file() {
2360        let db = FixtureDatabase::new();
2361
2362        // A test file with multiple fixtures with the same name (unusual but valid)
2363        let test_content = r#"
2364import pytest
2365
2366@pytest.fixture
2367def my_fixture():
2368    return "first"
2369
2370@pytest.fixture
2371def my_fixture():
2372    return "second"
2373
2374def test_something(my_fixture):
2375    assert my_fixture == "second"
2376"#;
2377        let test_path = PathBuf::from("/tmp/test/test_example.py");
2378        db.analyze_file(test_path.clone(), test_content);
2379
2380        // When there are multiple definitions in the same file, the later one should win
2381        // (Python's behavior - later definitions override earlier ones)
2382
2383        // Test go-to-definition on the parameter in test_something
2384        // Line 12 is "def test_something(my_fixture):" (1-indexed)
2385        // Character position 19 is the 'm' in "my_fixture"
2386        // LSP uses 0-indexed lines, so line 11 in LSP coordinates
2387
2388        let result = db.find_fixture_definition(&test_path, 11, 19);
2389
2390        assert!(result.is_some(), "Should find fixture definition");
2391        let def = result.unwrap();
2392        assert_eq!(def.name, "my_fixture");
2393        assert_eq!(def.file_path, test_path);
2394        // The current implementation returns the first match in the same file
2395        // For true Python semantics, we'd want the last one, but that's a more complex change
2396        // For now, we just verify it finds *a* definition in the same file
2397    }
2398
2399    #[test]
2400    fn test_fixture_overriding_conftest_hierarchy() {
2401        let db = FixtureDatabase::new();
2402
2403        // Root conftest.py
2404        let root_conftest_content = r#"
2405import pytest
2406
2407@pytest.fixture
2408def shared_fixture():
2409    return "root"
2410"#;
2411        let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2412        db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2413
2414        // Subdirectory conftest.py that overrides the fixture
2415        let sub_conftest_content = r#"
2416import pytest
2417
2418@pytest.fixture
2419def shared_fixture():
2420    return "subdir"
2421"#;
2422        let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2423        db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2424
2425        // Test file in subdirectory
2426        let test_content = r#"
2427def test_something(shared_fixture):
2428    assert shared_fixture == "subdir"
2429"#;
2430        let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
2431        db.analyze_file(test_path.clone(), test_content);
2432
2433        // Go-to-definition from the test should find the closest conftest.py (subdir)
2434        // Line 2 is "def test_something(shared_fixture):" (1-indexed)
2435        // Character position 19 is the 's' in "shared_fixture"
2436        // LSP uses 0-indexed lines, so line 1 in LSP coordinates
2437
2438        let result = db.find_fixture_definition(&test_path, 1, 19);
2439
2440        assert!(result.is_some(), "Should find fixture definition");
2441        let def = result.unwrap();
2442        assert_eq!(def.name, "shared_fixture");
2443        assert_eq!(
2444            def.file_path, sub_conftest_path,
2445            "Should resolve to closest conftest.py"
2446        );
2447
2448        // Now test from a file in the parent directory
2449        let parent_test_content = r#"
2450def test_parent(shared_fixture):
2451    assert shared_fixture == "root"
2452"#;
2453        let parent_test_path = PathBuf::from("/tmp/test/test_parent.py");
2454        db.analyze_file(parent_test_path.clone(), parent_test_content);
2455
2456        let result = db.find_fixture_definition(&parent_test_path, 1, 16);
2457
2458        assert!(result.is_some(), "Should find fixture definition");
2459        let def = result.unwrap();
2460        assert_eq!(def.name, "shared_fixture");
2461        assert_eq!(
2462            def.file_path, root_conftest_path,
2463            "Should resolve to root conftest.py"
2464        );
2465    }
2466
2467    #[test]
2468    fn test_scoped_references() {
2469        let db = FixtureDatabase::new();
2470
2471        // Set up a root conftest.py with a fixture
2472        let root_conftest_content = r#"
2473import pytest
2474
2475@pytest.fixture
2476def shared_fixture():
2477    return "root"
2478"#;
2479        let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2480        db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2481
2482        // Set up subdirectory conftest.py that overrides the fixture
2483        let sub_conftest_content = r#"
2484import pytest
2485
2486@pytest.fixture
2487def shared_fixture():
2488    return "subdir"
2489"#;
2490        let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2491        db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2492
2493        // Test file in the root directory (uses root fixture)
2494        let root_test_content = r#"
2495def test_root(shared_fixture):
2496    assert shared_fixture == "root"
2497"#;
2498        let root_test_path = PathBuf::from("/tmp/test/test_root.py");
2499        db.analyze_file(root_test_path.clone(), root_test_content);
2500
2501        // Test file in subdirectory (uses subdir fixture)
2502        let sub_test_content = r#"
2503def test_sub(shared_fixture):
2504    assert shared_fixture == "subdir"
2505"#;
2506        let sub_test_path = PathBuf::from("/tmp/test/subdir/test_sub.py");
2507        db.analyze_file(sub_test_path.clone(), sub_test_content);
2508
2509        // Another test in subdirectory
2510        let sub_test2_content = r#"
2511def test_sub2(shared_fixture):
2512    assert shared_fixture == "subdir"
2513"#;
2514        let sub_test2_path = PathBuf::from("/tmp/test/subdir/test_sub2.py");
2515        db.analyze_file(sub_test2_path.clone(), sub_test2_content);
2516
2517        // Get the root definition
2518        let root_definitions = db.definitions.get("shared_fixture").unwrap();
2519        let root_definition = root_definitions
2520            .iter()
2521            .find(|d| d.file_path == root_conftest_path)
2522            .unwrap();
2523
2524        // Get the subdir definition
2525        let sub_definition = root_definitions
2526            .iter()
2527            .find(|d| d.file_path == sub_conftest_path)
2528            .unwrap();
2529
2530        // Find references for the root definition
2531        let root_refs = db.find_references_for_definition(root_definition);
2532
2533        // Should only include the test in the root directory
2534        assert_eq!(
2535            root_refs.len(),
2536            1,
2537            "Root definition should have 1 reference (from root test)"
2538        );
2539        assert_eq!(root_refs[0].file_path, root_test_path);
2540
2541        // Find references for the subdir definition
2542        let sub_refs = db.find_references_for_definition(sub_definition);
2543
2544        // Should include both tests in the subdirectory
2545        assert_eq!(
2546            sub_refs.len(),
2547            2,
2548            "Subdir definition should have 2 references (from subdir tests)"
2549        );
2550
2551        let sub_ref_paths: Vec<_> = sub_refs.iter().map(|r| &r.file_path).collect();
2552        assert!(sub_ref_paths.contains(&&sub_test_path));
2553        assert!(sub_ref_paths.contains(&&sub_test2_path));
2554
2555        // Verify that all references by name returns 3 total
2556        let all_refs = db.find_fixture_references("shared_fixture");
2557        assert_eq!(
2558            all_refs.len(),
2559            3,
2560            "Should find 3 total references across all scopes"
2561        );
2562    }
2563
2564    #[test]
2565    fn test_multiline_parameters() {
2566        let db = FixtureDatabase::new();
2567
2568        // Conftest with fixture
2569        let conftest_content = r#"
2570import pytest
2571
2572@pytest.fixture
2573def foo():
2574    return 42
2575"#;
2576        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2577        db.analyze_file(conftest_path.clone(), conftest_content);
2578
2579        // Test file with multiline parameters
2580        let test_content = r#"
2581def test_xxx(
2582    foo,
2583):
2584    assert foo == 42
2585"#;
2586        let test_path = PathBuf::from("/tmp/test/test_example.py");
2587        db.analyze_file(test_path.clone(), test_content);
2588
2589        // Line 3 (1-indexed) is "    foo," - the parameter line
2590        // In LSP coordinates, that's line 2 (0-indexed)
2591        // Character position 4 is the 'f' in 'foo'
2592
2593        // Debug: Check what usages were recorded
2594        if let Some(usages) = db.usages.get(&test_path) {
2595            println!("Usages recorded:");
2596            for usage in usages.iter() {
2597                println!("  {} at line {} (1-indexed)", usage.name, usage.line);
2598            }
2599        } else {
2600            println!("No usages recorded for test file");
2601        }
2602
2603        // The content has a leading newline, so:
2604        // Line 1: (empty)
2605        // Line 2: def test_xxx(
2606        // Line 3:     foo,
2607        // Line 4: ):
2608        // Line 5:     assert foo == 42
2609
2610        // foo is at line 3 (1-indexed) = line 2 (0-indexed LSP)
2611        let result = db.find_fixture_definition(&test_path, 2, 4);
2612
2613        assert!(
2614            result.is_some(),
2615            "Should find fixture definition when cursor is on parameter line"
2616        );
2617        let def = result.unwrap();
2618        assert_eq!(def.name, "foo");
2619    }
2620
2621    #[test]
2622    fn test_find_references_from_usage() {
2623        let db = FixtureDatabase::new();
2624
2625        // Simple fixture and usage in the same file
2626        let test_content = r#"
2627import pytest
2628
2629@pytest.fixture
2630def foo(): ...
2631
2632
2633def test_xxx(foo):
2634    pass
2635"#;
2636        let test_path = PathBuf::from("/tmp/test/test_example.py");
2637        db.analyze_file(test_path.clone(), test_content);
2638
2639        // Get the foo definition
2640        let foo_defs = db.definitions.get("foo").unwrap();
2641        assert_eq!(foo_defs.len(), 1, "Should have exactly one foo definition");
2642        let foo_def = &foo_defs[0];
2643        assert_eq!(foo_def.line, 5, "foo definition should be on line 5");
2644
2645        // Get references for the definition
2646        let refs_from_def = db.find_references_for_definition(foo_def);
2647        println!("References from definition:");
2648        for r in &refs_from_def {
2649            println!("  {} at line {}", r.name, r.line);
2650        }
2651
2652        assert_eq!(
2653            refs_from_def.len(),
2654            1,
2655            "Should find 1 usage reference (test_xxx parameter)"
2656        );
2657        assert_eq!(refs_from_def[0].line, 8, "Usage should be on line 8");
2658
2659        // Now simulate what happens when user clicks on the usage (line 8, char 13 - the 'f' in 'foo')
2660        // This is LSP line 7 (0-indexed)
2661        let fixture_name = db.find_fixture_at_position(&test_path, 7, 13);
2662        println!(
2663            "\nfind_fixture_at_position(line 7, char 13): {:?}",
2664            fixture_name
2665        );
2666
2667        assert_eq!(
2668            fixture_name,
2669            Some("foo".to_string()),
2670            "Should find fixture name at usage position"
2671        );
2672
2673        let resolved_def = db.find_fixture_definition(&test_path, 7, 13);
2674        println!(
2675            "\nfind_fixture_definition(line 7, char 13): {:?}",
2676            resolved_def.as_ref().map(|d| (d.line, &d.file_path))
2677        );
2678
2679        assert!(resolved_def.is_some(), "Should resolve usage to definition");
2680        assert_eq!(
2681            resolved_def.unwrap(),
2682            *foo_def,
2683            "Should resolve to the correct definition"
2684        );
2685    }
2686
2687    #[test]
2688    fn test_find_references_with_ellipsis_body() {
2689        // This reproduces the structure from strawberry test_codegen.py
2690        let db = FixtureDatabase::new();
2691
2692        let test_content = r#"@pytest.fixture
2693def foo(): ...
2694
2695
2696def test_xxx(foo):
2697    pass
2698"#;
2699        let test_path = PathBuf::from("/tmp/test/test_codegen.py");
2700        db.analyze_file(test_path.clone(), test_content);
2701
2702        // Check what line foo definition is on
2703        let foo_defs = db.definitions.get("foo");
2704        println!(
2705            "foo definitions: {:?}",
2706            foo_defs
2707                .as_ref()
2708                .map(|defs| defs.iter().map(|d| d.line).collect::<Vec<_>>())
2709        );
2710
2711        // Check what line foo usage is on
2712        if let Some(usages) = db.usages.get(&test_path) {
2713            println!("usages:");
2714            for u in usages.iter() {
2715                println!("  {} at line {}", u.name, u.line);
2716            }
2717        }
2718
2719        assert!(foo_defs.is_some(), "Should find foo definition");
2720        let foo_def = &foo_defs.unwrap()[0];
2721
2722        // Get the usage line
2723        let usages = db.usages.get(&test_path).unwrap();
2724        let foo_usage = usages.iter().find(|u| u.name == "foo").unwrap();
2725
2726        // Test from usage position (LSP coordinates are 0-indexed)
2727        let usage_lsp_line = (foo_usage.line - 1) as u32;
2728        println!("\nTesting from usage at LSP line {}", usage_lsp_line);
2729
2730        let fixture_name = db.find_fixture_at_position(&test_path, usage_lsp_line, 13);
2731        assert_eq!(
2732            fixture_name,
2733            Some("foo".to_string()),
2734            "Should find foo at usage"
2735        );
2736
2737        let def_from_usage = db.find_fixture_definition(&test_path, usage_lsp_line, 13);
2738        assert!(
2739            def_from_usage.is_some(),
2740            "Should resolve usage to definition"
2741        );
2742        assert_eq!(def_from_usage.unwrap(), *foo_def);
2743    }
2744
2745    #[test]
2746    fn test_fixture_hierarchy_parent_references() {
2747        // Test that finding references from a parent fixture definition
2748        // includes child fixture definitions but NOT the child's usages
2749        let db = FixtureDatabase::new();
2750
2751        // Parent conftest
2752        let parent_content = r#"
2753import pytest
2754
2755@pytest.fixture
2756def cli_runner():
2757    """Parent fixture"""
2758    return "parent"
2759"#;
2760        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2761        db.analyze_file(parent_conftest.clone(), parent_content);
2762
2763        // Child conftest with override
2764        let child_content = r#"
2765import pytest
2766
2767@pytest.fixture
2768def cli_runner(cli_runner):
2769    """Child override that uses parent"""
2770    return cli_runner
2771"#;
2772        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2773        db.analyze_file(child_conftest.clone(), child_content);
2774
2775        // Test file in subdir using the child fixture
2776        let test_content = r#"
2777def test_one(cli_runner):
2778    pass
2779
2780def test_two(cli_runner):
2781    pass
2782"#;
2783        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2784        db.analyze_file(test_path.clone(), test_content);
2785
2786        // Get parent definition
2787        let parent_defs = db.definitions.get("cli_runner").unwrap();
2788        let parent_def = parent_defs
2789            .iter()
2790            .find(|d| d.file_path == parent_conftest)
2791            .unwrap();
2792
2793        println!(
2794            "\nParent definition: {:?}:{}",
2795            parent_def.file_path, parent_def.line
2796        );
2797
2798        // Find references for parent definition
2799        let refs = db.find_references_for_definition(parent_def);
2800
2801        println!("\nReferences for parent definition:");
2802        for r in &refs {
2803            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2804        }
2805
2806        // Parent references should include:
2807        // 1. The child fixture definition (line 5 in child conftest)
2808        // 2. The child's parameter that references the parent (line 5 in child conftest)
2809        // But NOT:
2810        // 3. test_one and test_two usages (they resolve to child, not parent)
2811
2812        assert!(
2813            refs.len() <= 2,
2814            "Parent should have at most 2 references: child definition and its parameter, got {}",
2815            refs.len()
2816        );
2817
2818        // Should include the child conftest
2819        let child_refs: Vec<_> = refs
2820            .iter()
2821            .filter(|r| r.file_path == child_conftest)
2822            .collect();
2823        assert!(
2824            !child_refs.is_empty(),
2825            "Parent references should include child fixture definition"
2826        );
2827
2828        // Should NOT include test file usages
2829        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2830        assert!(
2831            test_refs.is_empty(),
2832            "Parent references should NOT include child's test file usages"
2833        );
2834    }
2835
2836    #[test]
2837    fn test_fixture_hierarchy_child_references() {
2838        // Test that finding references from a child fixture definition
2839        // includes usages in the same directory (that resolve to the child)
2840        let db = FixtureDatabase::new();
2841
2842        // Parent conftest
2843        let parent_content = r#"
2844import pytest
2845
2846@pytest.fixture
2847def cli_runner():
2848    return "parent"
2849"#;
2850        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2851        db.analyze_file(parent_conftest.clone(), parent_content);
2852
2853        // Child conftest with override
2854        let child_content = r#"
2855import pytest
2856
2857@pytest.fixture
2858def cli_runner(cli_runner):
2859    return cli_runner
2860"#;
2861        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2862        db.analyze_file(child_conftest.clone(), child_content);
2863
2864        // Test file using child fixture
2865        let test_content = r#"
2866def test_one(cli_runner):
2867    pass
2868
2869def test_two(cli_runner):
2870    pass
2871"#;
2872        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2873        db.analyze_file(test_path.clone(), test_content);
2874
2875        // Get child definition
2876        let child_defs = db.definitions.get("cli_runner").unwrap();
2877        let child_def = child_defs
2878            .iter()
2879            .find(|d| d.file_path == child_conftest)
2880            .unwrap();
2881
2882        println!(
2883            "\nChild definition: {:?}:{}",
2884            child_def.file_path, child_def.line
2885        );
2886
2887        // Find references for child definition
2888        let refs = db.find_references_for_definition(child_def);
2889
2890        println!("\nReferences for child definition:");
2891        for r in &refs {
2892            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2893        }
2894
2895        // Child references should include test_one and test_two
2896        assert!(
2897            refs.len() >= 2,
2898            "Child should have at least 2 references from test file, got {}",
2899            refs.len()
2900        );
2901
2902        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2903        assert_eq!(
2904            test_refs.len(),
2905            2,
2906            "Should have 2 references from test file"
2907        );
2908    }
2909
2910    #[test]
2911    fn test_fixture_hierarchy_child_parameter_references() {
2912        // Test that finding references from a child fixture's parameter
2913        // (which references the parent) includes the child fixture definition
2914        let db = FixtureDatabase::new();
2915
2916        // Parent conftest
2917        let parent_content = r#"
2918import pytest
2919
2920@pytest.fixture
2921def cli_runner():
2922    return "parent"
2923"#;
2924        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2925        db.analyze_file(parent_conftest.clone(), parent_content);
2926
2927        // Child conftest with override
2928        let child_content = r#"
2929import pytest
2930
2931@pytest.fixture
2932def cli_runner(cli_runner):
2933    return cli_runner
2934"#;
2935        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2936        db.analyze_file(child_conftest.clone(), child_content);
2937
2938        // When user clicks on the parameter "cli_runner" in the child definition,
2939        // it should resolve to the parent definition
2940        // Line 5 (1-indexed) = line 4 (0-indexed LSP), char 15 is in the parameter name
2941        let resolved_def = db.find_fixture_definition(&child_conftest, 4, 15);
2942
2943        assert!(
2944            resolved_def.is_some(),
2945            "Child parameter should resolve to parent definition"
2946        );
2947
2948        let def = resolved_def.unwrap();
2949        assert_eq!(
2950            def.file_path, parent_conftest,
2951            "Should resolve to parent conftest"
2952        );
2953
2954        // Find references for parent definition
2955        let refs = db.find_references_for_definition(&def);
2956
2957        println!("\nReferences for parent (from child parameter):");
2958        for r in &refs {
2959            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2960        }
2961
2962        // Should include the child fixture's parameter usage
2963        let child_refs: Vec<_> = refs
2964            .iter()
2965            .filter(|r| r.file_path == child_conftest)
2966            .collect();
2967        assert!(
2968            !child_refs.is_empty(),
2969            "Parent references should include child fixture parameter"
2970        );
2971    }
2972
2973    #[test]
2974    fn test_fixture_hierarchy_usage_from_test() {
2975        // Test that finding references from a test function parameter
2976        // includes the definition it resolves to and other usages
2977        let db = FixtureDatabase::new();
2978
2979        // Parent conftest
2980        let parent_content = r#"
2981import pytest
2982
2983@pytest.fixture
2984def cli_runner():
2985    return "parent"
2986"#;
2987        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2988        db.analyze_file(parent_conftest.clone(), parent_content);
2989
2990        // Child conftest with override
2991        let child_content = r#"
2992import pytest
2993
2994@pytest.fixture
2995def cli_runner(cli_runner):
2996    return cli_runner
2997"#;
2998        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2999        db.analyze_file(child_conftest.clone(), child_content);
3000
3001        // Test file using child fixture
3002        let test_content = r#"
3003def test_one(cli_runner):
3004    pass
3005
3006def test_two(cli_runner):
3007    pass
3008
3009def test_three(cli_runner):
3010    pass
3011"#;
3012        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
3013        db.analyze_file(test_path.clone(), test_content);
3014
3015        // Click on cli_runner in test_one (line 2, 1-indexed = line 1, 0-indexed)
3016        let resolved_def = db.find_fixture_definition(&test_path, 1, 13);
3017
3018        assert!(
3019            resolved_def.is_some(),
3020            "Usage should resolve to child definition"
3021        );
3022
3023        let def = resolved_def.unwrap();
3024        assert_eq!(
3025            def.file_path, child_conftest,
3026            "Should resolve to child conftest (not parent)"
3027        );
3028
3029        // Find references for the resolved definition
3030        let refs = db.find_references_for_definition(&def);
3031
3032        println!("\nReferences for child (from test usage):");
3033        for r in &refs {
3034            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
3035        }
3036
3037        // Should include all three test usages
3038        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
3039        assert_eq!(test_refs.len(), 3, "Should find all 3 usages in test file");
3040    }
3041
3042    #[test]
3043    fn test_fixture_hierarchy_multiple_levels() {
3044        // Test a three-level hierarchy: grandparent -> parent -> child
3045        let db = FixtureDatabase::new();
3046
3047        // Grandparent
3048        let grandparent_content = r#"
3049import pytest
3050
3051@pytest.fixture
3052def db():
3053    return "grandparent_db"
3054"#;
3055        let grandparent_conftest = PathBuf::from("/tmp/project/conftest.py");
3056        db.analyze_file(grandparent_conftest.clone(), grandparent_content);
3057
3058        // Parent overrides
3059        let parent_content = r#"
3060import pytest
3061
3062@pytest.fixture
3063def db(db):
3064    return f"parent_{db}"
3065"#;
3066        let parent_conftest = PathBuf::from("/tmp/project/api/conftest.py");
3067        db.analyze_file(parent_conftest.clone(), parent_content);
3068
3069        // Child overrides again
3070        let child_content = r#"
3071import pytest
3072
3073@pytest.fixture
3074def db(db):
3075    return f"child_{db}"
3076"#;
3077        let child_conftest = PathBuf::from("/tmp/project/api/tests/conftest.py");
3078        db.analyze_file(child_conftest.clone(), child_content);
3079
3080        // Test file at child level
3081        let test_content = r#"
3082def test_db(db):
3083    pass
3084"#;
3085        let test_path = PathBuf::from("/tmp/project/api/tests/test_example.py");
3086        db.analyze_file(test_path.clone(), test_content);
3087
3088        // Get all definitions
3089        let all_defs = db.definitions.get("db").unwrap();
3090        assert_eq!(all_defs.len(), 3, "Should have 3 definitions");
3091
3092        let grandparent_def = all_defs
3093            .iter()
3094            .find(|d| d.file_path == grandparent_conftest)
3095            .unwrap();
3096        let parent_def = all_defs
3097            .iter()
3098            .find(|d| d.file_path == parent_conftest)
3099            .unwrap();
3100        let child_def = all_defs
3101            .iter()
3102            .find(|d| d.file_path == child_conftest)
3103            .unwrap();
3104
3105        // Test from test file - should resolve to child
3106        let resolved = db.find_fixture_definition(&test_path, 1, 12);
3107        assert_eq!(
3108            resolved.as_ref(),
3109            Some(child_def),
3110            "Test should use child definition"
3111        );
3112
3113        // Child's references should include test file
3114        let child_refs = db.find_references_for_definition(child_def);
3115        let test_refs: Vec<_> = child_refs
3116            .iter()
3117            .filter(|r| r.file_path == test_path)
3118            .collect();
3119        assert!(
3120            !test_refs.is_empty(),
3121            "Child should have test file references"
3122        );
3123
3124        // Parent's references should include child's parameter, but not test file
3125        let parent_refs = db.find_references_for_definition(parent_def);
3126        let child_param_refs: Vec<_> = parent_refs
3127            .iter()
3128            .filter(|r| r.file_path == child_conftest)
3129            .collect();
3130        let test_refs_in_parent: Vec<_> = parent_refs
3131            .iter()
3132            .filter(|r| r.file_path == test_path)
3133            .collect();
3134
3135        assert!(
3136            !child_param_refs.is_empty(),
3137            "Parent should have child parameter reference"
3138        );
3139        assert!(
3140            test_refs_in_parent.is_empty(),
3141            "Parent should NOT have test file references"
3142        );
3143
3144        // Grandparent's references should include parent's parameter, but not child's stuff
3145        let grandparent_refs = db.find_references_for_definition(grandparent_def);
3146        let parent_param_refs: Vec<_> = grandparent_refs
3147            .iter()
3148            .filter(|r| r.file_path == parent_conftest)
3149            .collect();
3150        let child_refs_in_gp: Vec<_> = grandparent_refs
3151            .iter()
3152            .filter(|r| r.file_path == child_conftest)
3153            .collect();
3154
3155        assert!(
3156            !parent_param_refs.is_empty(),
3157            "Grandparent should have parent parameter reference"
3158        );
3159        assert!(
3160            child_refs_in_gp.is_empty(),
3161            "Grandparent should NOT have child references"
3162        );
3163    }
3164
3165    #[test]
3166    fn test_fixture_hierarchy_same_file_override() {
3167        // Test that a fixture can be overridden in the same file
3168        // (less common but valid pytest pattern)
3169        let db = FixtureDatabase::new();
3170
3171        let content = r#"
3172import pytest
3173
3174@pytest.fixture
3175def base():
3176    return "base"
3177
3178@pytest.fixture
3179def base(base):
3180    return f"override_{base}"
3181
3182def test_uses_override(base):
3183    pass
3184"#;
3185        let test_path = PathBuf::from("/tmp/test/test_example.py");
3186        db.analyze_file(test_path.clone(), content);
3187
3188        let defs = db.definitions.get("base").unwrap();
3189        assert_eq!(defs.len(), 2, "Should have 2 definitions in same file");
3190
3191        println!("\nDefinitions found:");
3192        for d in defs.iter() {
3193            println!("  base at line {}", d.line);
3194        }
3195
3196        // Check usages
3197        if let Some(usages) = db.usages.get(&test_path) {
3198            println!("\nUsages found:");
3199            for u in usages.iter() {
3200                println!("  {} at line {}", u.name, u.line);
3201            }
3202        } else {
3203            println!("\nNo usages found!");
3204        }
3205
3206        // The test should resolve to the second definition (override)
3207        // Line 12 (1-indexed) = line 11 (0-indexed LSP)
3208        // Character position: "def test_uses_override(base):" - 'b' is at position 23
3209        let resolved = db.find_fixture_definition(&test_path, 11, 23);
3210
3211        println!("\nResolved: {:?}", resolved.as_ref().map(|d| d.line));
3212
3213        assert!(resolved.is_some(), "Should resolve to override definition");
3214
3215        // The second definition should be at line 9 (1-indexed)
3216        let override_def = defs.iter().find(|d| d.line == 9).unwrap();
3217        println!("Override def at line: {}", override_def.line);
3218        assert_eq!(resolved.as_ref(), Some(override_def));
3219    }
3220
3221    #[test]
3222    fn test_cursor_position_on_definition_line() {
3223        // Debug test to understand what happens at different cursor positions
3224        // on a fixture definition line with a self-referencing parameter
3225        let db = FixtureDatabase::new();
3226
3227        // Add a parent conftest with parent fixture
3228        let parent_content = r#"
3229import pytest
3230
3231@pytest.fixture
3232def cli_runner():
3233    return "parent"
3234"#;
3235        let parent_conftest = PathBuf::from("/tmp/conftest.py");
3236        db.analyze_file(parent_conftest.clone(), parent_content);
3237
3238        let content = r#"
3239import pytest
3240
3241@pytest.fixture
3242def cli_runner(cli_runner):
3243    return cli_runner
3244"#;
3245        let test_path = PathBuf::from("/tmp/test/test_example.py");
3246        db.analyze_file(test_path.clone(), content);
3247
3248        // Line 5 (1-indexed): "def cli_runner(cli_runner):"
3249        // Position 0: 'd' in def
3250        // Position 4: 'c' in cli_runner (function name)
3251        // Position 15: '('
3252        // Position 16: 'c' in cli_runner (parameter name)
3253
3254        println!("\n=== Testing character positions on line 5 ===");
3255
3256        // Check usages
3257        if let Some(usages) = db.usages.get(&test_path) {
3258            println!("\nUsages found:");
3259            for u in usages.iter() {
3260                println!(
3261                    "  {} at line {}, chars {}-{}",
3262                    u.name, u.line, u.start_char, u.end_char
3263                );
3264            }
3265        } else {
3266            println!("\nNo usages found!");
3267        }
3268
3269        // Test clicking on function name 'cli_runner' - should be treated as definition
3270        let line_content = "def cli_runner(cli_runner):";
3271        println!("\nLine content: '{}'", line_content);
3272
3273        // Position 4 = 'c' in function name cli_runner
3274        println!("\nPosition 4 (function name):");
3275        let word_at_4 = db.extract_word_at_position(line_content, 4);
3276        println!("  Word at cursor: {:?}", word_at_4);
3277        let fixture_name_at_4 = db.find_fixture_at_position(&test_path, 4, 4);
3278        println!("  find_fixture_at_position: {:?}", fixture_name_at_4);
3279        let resolved_4 = db.find_fixture_definition(&test_path, 4, 4); // Line 5 = index 4
3280        println!(
3281            "  Resolved: {:?}",
3282            resolved_4.as_ref().map(|d| (d.name.as_str(), d.line))
3283        );
3284
3285        // Position 16 = 'c' in parameter name cli_runner
3286        println!("\nPosition 16 (parameter name):");
3287        let word_at_16 = db.extract_word_at_position(line_content, 16);
3288        println!("  Word at cursor: {:?}", word_at_16);
3289
3290        // Manual check: does the usage check work?
3291        if let Some(usages) = db.usages.get(&test_path) {
3292            for usage in usages.iter() {
3293                println!("  Checking usage: {} at line {}", usage.name, usage.line);
3294                if usage.line == 5 && usage.name == "cli_runner" {
3295                    println!("    MATCH! Usage matches our position");
3296                }
3297            }
3298        }
3299
3300        let fixture_name_at_16 = db.find_fixture_at_position(&test_path, 4, 16);
3301        println!("  find_fixture_at_position: {:?}", fixture_name_at_16);
3302        let resolved_16 = db.find_fixture_definition(&test_path, 4, 16); // Line 5 = index 4
3303        println!(
3304            "  Resolved: {:?}",
3305            resolved_16.as_ref().map(|d| (d.name.as_str(), d.line))
3306        );
3307
3308        // Expected behavior:
3309        // - Position 4 (function name): should resolve to CHILD (line 5) - we're ON the definition
3310        // - Position 16 (parameter): should resolve to PARENT (line 5 in conftest) - it's a usage
3311
3312        assert_eq!(word_at_4, Some("cli_runner".to_string()));
3313        assert_eq!(word_at_16, Some("cli_runner".to_string()));
3314
3315        // Check the actual resolution
3316        println!("\n=== ACTUAL vs EXPECTED ===");
3317        println!("Position 4 (function name):");
3318        println!(
3319            "  Actual: {:?}",
3320            resolved_4.as_ref().map(|d| (&d.file_path, d.line))
3321        );
3322        println!("  Expected: test file, line 5 (the child definition itself)");
3323
3324        println!("\nPosition 16 (parameter):");
3325        println!(
3326            "  Actual: {:?}",
3327            resolved_16.as_ref().map(|d| (&d.file_path, d.line))
3328        );
3329        println!("  Expected: conftest, line 5 (the parent definition)");
3330
3331        // The BUG: both return the same thing (child at line 5)
3332        // Position 4: returning child is CORRECT (though find_fixture_definition returns None,
3333        //             main.rs falls back to get_definition_at_line which is correct)
3334        // Position 16: returning child is WRONG - should return parent (line 5 in conftest)
3335
3336        if let Some(ref def) = resolved_16 {
3337            assert_eq!(
3338                def.file_path, parent_conftest,
3339                "Parameter should resolve to parent definition"
3340            );
3341        } else {
3342            panic!("Position 16 (parameter) should resolve to parent definition");
3343        }
3344    }
3345
3346    #[test]
3347    fn test_undeclared_fixture_detection_in_test() {
3348        let db = FixtureDatabase::new();
3349
3350        // Add a fixture definition in conftest
3351        let conftest_content = r#"
3352import pytest
3353
3354@pytest.fixture
3355def my_fixture():
3356    return 42
3357"#;
3358        let conftest_path = PathBuf::from("/tmp/conftest.py");
3359        db.analyze_file(conftest_path.clone(), conftest_content);
3360
3361        // Add a test that uses the fixture without declaring it
3362        let test_content = r#"
3363def test_example():
3364    result = my_fixture.get()
3365    assert result == 42
3366"#;
3367        let test_path = PathBuf::from("/tmp/test_example.py");
3368        db.analyze_file(test_path.clone(), test_content);
3369
3370        // Check that undeclared fixture was detected
3371        let undeclared = db.get_undeclared_fixtures(&test_path);
3372        assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3373
3374        let fixture = &undeclared[0];
3375        assert_eq!(fixture.name, "my_fixture");
3376        assert_eq!(fixture.function_name, "test_example");
3377        assert_eq!(fixture.line, 3); // Line 3: "result = my_fixture.get()"
3378    }
3379
3380    #[test]
3381    fn test_undeclared_fixture_detection_in_fixture() {
3382        let db = FixtureDatabase::new();
3383
3384        // Add fixture definitions in conftest
3385        let conftest_content = r#"
3386import pytest
3387
3388@pytest.fixture
3389def base_fixture():
3390    return "base"
3391
3392@pytest.fixture
3393def helper_fixture():
3394    return "helper"
3395"#;
3396        let conftest_path = PathBuf::from("/tmp/conftest.py");
3397        db.analyze_file(conftest_path.clone(), conftest_content);
3398
3399        // Add a fixture that uses another fixture without declaring it
3400        let test_content = r#"
3401import pytest
3402
3403@pytest.fixture
3404def my_fixture(base_fixture):
3405    data = helper_fixture.value
3406    return f"{base_fixture}-{data}"
3407"#;
3408        let test_path = PathBuf::from("/tmp/test_example.py");
3409        db.analyze_file(test_path.clone(), test_content);
3410
3411        // Check that undeclared fixture was detected
3412        let undeclared = db.get_undeclared_fixtures(&test_path);
3413        assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3414
3415        let fixture = &undeclared[0];
3416        assert_eq!(fixture.name, "helper_fixture");
3417        assert_eq!(fixture.function_name, "my_fixture");
3418        assert_eq!(fixture.line, 6); // Line 6: "data = helper_fixture.value"
3419    }
3420
3421    #[test]
3422    fn test_no_false_positive_for_declared_fixtures() {
3423        let db = FixtureDatabase::new();
3424
3425        // Add a fixture definition in conftest
3426        let conftest_content = r#"
3427import pytest
3428
3429@pytest.fixture
3430def my_fixture():
3431    return 42
3432"#;
3433        let conftest_path = PathBuf::from("/tmp/conftest.py");
3434        db.analyze_file(conftest_path.clone(), conftest_content);
3435
3436        // Add a test that properly declares the fixture as a parameter
3437        let test_content = r#"
3438def test_example(my_fixture):
3439    result = my_fixture
3440    assert result == 42
3441"#;
3442        let test_path = PathBuf::from("/tmp/test_example.py");
3443        db.analyze_file(test_path.clone(), test_content);
3444
3445        // Check that no undeclared fixtures were detected
3446        let undeclared = db.get_undeclared_fixtures(&test_path);
3447        assert_eq!(
3448            undeclared.len(),
3449            0,
3450            "Should not detect any undeclared fixtures"
3451        );
3452    }
3453
3454    #[test]
3455    fn test_no_false_positive_for_non_fixtures() {
3456        let db = FixtureDatabase::new();
3457
3458        // Add a test that uses regular variables (not fixtures)
3459        let test_content = r#"
3460def test_example():
3461    my_variable = 42
3462    result = my_variable + 10
3463    assert result == 52
3464"#;
3465        let test_path = PathBuf::from("/tmp/test_example.py");
3466        db.analyze_file(test_path.clone(), test_content);
3467
3468        // Check that no undeclared fixtures were detected
3469        let undeclared = db.get_undeclared_fixtures(&test_path);
3470        assert_eq!(
3471            undeclared.len(),
3472            0,
3473            "Should not detect any undeclared fixtures"
3474        );
3475    }
3476
3477    #[test]
3478    fn test_undeclared_fixture_not_available_in_hierarchy() {
3479        let db = FixtureDatabase::new();
3480
3481        // Add a fixture in a different directory (not in hierarchy)
3482        let other_conftest = r#"
3483import pytest
3484
3485@pytest.fixture
3486def other_fixture():
3487    return "other"
3488"#;
3489        let other_path = PathBuf::from("/other/conftest.py");
3490        db.analyze_file(other_path.clone(), other_conftest);
3491
3492        // Add a test that uses a name that happens to match a fixture but isn't available
3493        let test_content = r#"
3494def test_example():
3495    result = other_fixture.value
3496    assert result == "other"
3497"#;
3498        let test_path = PathBuf::from("/tmp/test_example.py");
3499        db.analyze_file(test_path.clone(), test_content);
3500
3501        // Should not detect undeclared fixture because it's not in the hierarchy
3502        let undeclared = db.get_undeclared_fixtures(&test_path);
3503        assert_eq!(
3504            undeclared.len(),
3505            0,
3506            "Should not detect fixtures not in hierarchy"
3507        );
3508    }
3509}
3510
3511#[test]
3512fn test_undeclared_fixture_in_async_test() {
3513    let db = FixtureDatabase::new();
3514
3515    // Add fixture in same file
3516    let content = r#"
3517import pytest
3518
3519@pytest.fixture
3520def http_client():
3521    return "MockClient"
3522
3523async def test_with_undeclared():
3524    response = await http_client.query("test")
3525    assert response == "test"
3526"#;
3527    let test_path = PathBuf::from("/tmp/test_example.py");
3528    db.analyze_file(test_path.clone(), content);
3529
3530    // Check that undeclared fixture was detected
3531    let undeclared = db.get_undeclared_fixtures(&test_path);
3532
3533    println!("Found {} undeclared fixtures", undeclared.len());
3534    for u in &undeclared {
3535        println!("  - {} at line {} in {}", u.name, u.line, u.function_name);
3536    }
3537
3538    assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3539    assert_eq!(undeclared[0].name, "http_client");
3540    assert_eq!(undeclared[0].function_name, "test_with_undeclared");
3541    assert_eq!(undeclared[0].line, 9);
3542}
3543
3544#[test]
3545fn test_undeclared_fixture_in_assert_statement() {
3546    let db = FixtureDatabase::new();
3547
3548    // Add fixture in conftest
3549    let conftest_content = r#"
3550import pytest
3551
3552@pytest.fixture
3553def expected_value():
3554    return 42
3555"#;
3556    let conftest_path = PathBuf::from("/tmp/conftest.py");
3557    db.analyze_file(conftest_path.clone(), conftest_content);
3558
3559    // Test file that uses fixture in assert without declaring it
3560    let test_content = r#"
3561def test_assertion():
3562    result = calculate_value()
3563    assert result == expected_value
3564"#;
3565    let test_path = PathBuf::from("/tmp/test_example.py");
3566    db.analyze_file(test_path.clone(), test_content);
3567
3568    // Check that undeclared fixture was detected in assert
3569    let undeclared = db.get_undeclared_fixtures(&test_path);
3570
3571    assert_eq!(
3572        undeclared.len(),
3573        1,
3574        "Should detect one undeclared fixture in assert"
3575    );
3576    assert_eq!(undeclared[0].name, "expected_value");
3577    assert_eq!(undeclared[0].function_name, "test_assertion");
3578}
3579
3580#[test]
3581fn test_no_false_positive_for_local_variable() {
3582    // Problem 2: Should not warn if a local variable shadows a fixture name
3583    let db = FixtureDatabase::new();
3584
3585    // Add fixture in conftest
3586    let conftest_content = r#"
3587import pytest
3588
3589@pytest.fixture
3590def foo():
3591    return "fixture"
3592"#;
3593    let conftest_path = PathBuf::from("/tmp/conftest.py");
3594    db.analyze_file(conftest_path.clone(), conftest_content);
3595
3596    // Test file that has a local variable with the same name
3597    let test_content = r#"
3598def test_with_local_variable():
3599    foo = "local variable"
3600    result = foo.upper()
3601    assert result == "LOCAL VARIABLE"
3602"#;
3603    let test_path = PathBuf::from("/tmp/test_example.py");
3604    db.analyze_file(test_path.clone(), test_content);
3605
3606    // Should NOT detect undeclared fixture because foo is a local variable
3607    let undeclared = db.get_undeclared_fixtures(&test_path);
3608
3609    assert_eq!(
3610        undeclared.len(),
3611        0,
3612        "Should not detect undeclared fixture when name is a local variable"
3613    );
3614}
3615
3616#[test]
3617fn test_no_false_positive_for_imported_name() {
3618    // Problem 2: Should not warn if an imported name shadows a fixture name
3619    let db = FixtureDatabase::new();
3620
3621    // Add fixture in conftest
3622    let conftest_content = r#"
3623import pytest
3624
3625@pytest.fixture
3626def foo():
3627    return "fixture"
3628"#;
3629    let conftest_path = PathBuf::from("/tmp/conftest.py");
3630    db.analyze_file(conftest_path.clone(), conftest_content);
3631
3632    // Test file that imports a name
3633    let test_content = r#"
3634from mymodule import foo
3635
3636def test_with_import():
3637    result = foo.something()
3638    assert result == "value"
3639"#;
3640    let test_path = PathBuf::from("/tmp/test_example.py");
3641    db.analyze_file(test_path.clone(), test_content);
3642
3643    // Should NOT detect undeclared fixture because foo is imported
3644    let undeclared = db.get_undeclared_fixtures(&test_path);
3645
3646    assert_eq!(
3647        undeclared.len(),
3648        0,
3649        "Should not detect undeclared fixture when name is imported"
3650    );
3651}
3652
3653#[test]
3654fn test_warn_for_fixture_used_directly() {
3655    // Problem 2: SHOULD warn if trying to use a fixture defined in the same file
3656    // This is an error because fixtures must be accessed through parameters
3657    let db = FixtureDatabase::new();
3658
3659    let test_content = r#"
3660import pytest
3661
3662@pytest.fixture
3663def foo():
3664    return "fixture"
3665
3666def test_using_fixture_directly():
3667    # This is an error - fixtures must be declared as parameters
3668    result = foo.something()
3669    assert result == "value"
3670"#;
3671    let test_path = PathBuf::from("/tmp/test_example.py");
3672    db.analyze_file(test_path.clone(), test_content);
3673
3674    // SHOULD detect undeclared fixture even though foo is defined in same file
3675    let undeclared = db.get_undeclared_fixtures(&test_path);
3676
3677    assert_eq!(
3678        undeclared.len(),
3679        1,
3680        "Should detect fixture used directly without parameter declaration"
3681    );
3682    assert_eq!(undeclared[0].name, "foo");
3683    assert_eq!(undeclared[0].function_name, "test_using_fixture_directly");
3684}
3685
3686#[test]
3687fn test_no_false_positive_for_module_level_assignment() {
3688    // Should not warn if name is assigned at module level (not a fixture)
3689    let db = FixtureDatabase::new();
3690
3691    // Add fixture in conftest
3692    let conftest_content = r#"
3693import pytest
3694
3695@pytest.fixture
3696def foo():
3697    return "fixture"
3698"#;
3699    let conftest_path = PathBuf::from("/tmp/conftest.py");
3700    db.analyze_file(conftest_path.clone(), conftest_content);
3701
3702    // Test file that has a module-level assignment
3703    let test_content = r#"
3704# Module-level assignment
3705foo = SomeClass()
3706
3707def test_with_module_var():
3708    result = foo.method()
3709    assert result == "value"
3710"#;
3711    let test_path = PathBuf::from("/tmp/test_example.py");
3712    db.analyze_file(test_path.clone(), test_content);
3713
3714    // Should NOT detect undeclared fixture because foo is assigned at module level
3715    let undeclared = db.get_undeclared_fixtures(&test_path);
3716
3717    assert_eq!(
3718        undeclared.len(),
3719        0,
3720        "Should not detect undeclared fixture when name is assigned at module level"
3721    );
3722}
3723
3724#[test]
3725fn test_no_false_positive_for_function_definition() {
3726    // Should not warn if name is a regular function (not a fixture)
3727    let db = FixtureDatabase::new();
3728
3729    // Add fixture in conftest
3730    let conftest_content = r#"
3731import pytest
3732
3733@pytest.fixture
3734def foo():
3735    return "fixture"
3736"#;
3737    let conftest_path = PathBuf::from("/tmp/conftest.py");
3738    db.analyze_file(conftest_path.clone(), conftest_content);
3739
3740    // Test file that has a regular function with the same name
3741    let test_content = r#"
3742def foo():
3743    return "not a fixture"
3744
3745def test_with_function():
3746    result = foo()
3747    assert result == "not a fixture"
3748"#;
3749    let test_path = PathBuf::from("/tmp/test_example.py");
3750    db.analyze_file(test_path.clone(), test_content);
3751
3752    // Should NOT detect undeclared fixture because foo is a regular function
3753    let undeclared = db.get_undeclared_fixtures(&test_path);
3754
3755    assert_eq!(
3756        undeclared.len(),
3757        0,
3758        "Should not detect undeclared fixture when name is a regular function"
3759    );
3760}
3761
3762#[test]
3763fn test_no_false_positive_for_class_definition() {
3764    // Should not warn if name is a class
3765    let db = FixtureDatabase::new();
3766
3767    // Add fixture in conftest
3768    let conftest_content = r#"
3769import pytest
3770
3771@pytest.fixture
3772def MyClass():
3773    return "fixture"
3774"#;
3775    let conftest_path = PathBuf::from("/tmp/conftest.py");
3776    db.analyze_file(conftest_path.clone(), conftest_content);
3777
3778    // Test file that has a class with the same name
3779    let test_content = r#"
3780class MyClass:
3781    pass
3782
3783def test_with_class():
3784    obj = MyClass()
3785    assert obj is not None
3786"#;
3787    let test_path = PathBuf::from("/tmp/test_example.py");
3788    db.analyze_file(test_path.clone(), test_content);
3789
3790    // Should NOT detect undeclared fixture because MyClass is a class
3791    let undeclared = db.get_undeclared_fixtures(&test_path);
3792
3793    assert_eq!(
3794        undeclared.len(),
3795        0,
3796        "Should not detect undeclared fixture when name is a class"
3797    );
3798}
3799
3800#[test]
3801fn test_line_aware_local_variable_scope() {
3802    // Test that local variables are only considered "in scope" AFTER they're assigned
3803    let db = FixtureDatabase::new();
3804
3805    // Conftest with http_client fixture
3806    let conftest_content = r#"
3807import pytest
3808
3809@pytest.fixture
3810def http_client():
3811    return "MockClient"
3812"#;
3813    let conftest_path = PathBuf::from("/tmp/conftest.py");
3814    db.analyze_file(conftest_path.clone(), conftest_content);
3815
3816    // Test file that uses http_client before and after a local assignment
3817    let test_content = r#"async def test_example():
3818    # Line 1: http_client should be flagged (not yet assigned)
3819    result = await http_client.get("/api")
3820    # Line 3: Now we assign http_client locally
3821    http_client = "local"
3822    # Line 5: http_client should NOT be flagged (local var now)
3823    result2 = await http_client.get("/api2")
3824"#;
3825    let test_path = PathBuf::from("/tmp/test_example.py");
3826    db.analyze_file(test_path.clone(), test_content);
3827
3828    // Check for undeclared fixtures
3829    let undeclared = db.get_undeclared_fixtures(&test_path);
3830
3831    // Should only detect http_client on line 3 (usage before assignment)
3832    // NOT on line 7 (after assignment on line 5)
3833    assert_eq!(
3834        undeclared.len(),
3835        1,
3836        "Should detect http_client only before local assignment"
3837    );
3838    assert_eq!(undeclared[0].name, "http_client");
3839    // Line numbers: 1=def, 2=comment, 3=result (first usage), 4=comment, 5=assignment, 6=comment, 7=result2
3840    assert_eq!(
3841        undeclared[0].line, 3,
3842        "Should flag usage on line 3 (before assignment on line 5)"
3843    );
3844}
3845
3846#[test]
3847fn test_same_line_assignment_and_usage() {
3848    // Test that usage on the same line as assignment refers to the fixture
3849    let db = FixtureDatabase::new();
3850
3851    let conftest_content = r#"import pytest
3852
3853@pytest.fixture
3854def http_client():
3855    return "parent"
3856"#;
3857    let conftest_path = PathBuf::from("/tmp/conftest.py");
3858    db.analyze_file(conftest_path.clone(), conftest_content);
3859
3860    let test_content = r#"async def test_example():
3861    # This references the fixture on the RHS, then assigns to local var
3862    http_client = await http_client.get("/api")
3863"#;
3864    let test_path = PathBuf::from("/tmp/test_example.py");
3865    db.analyze_file(test_path.clone(), test_content);
3866
3867    let undeclared = db.get_undeclared_fixtures(&test_path);
3868
3869    // Should detect http_client on RHS (line 3) because assignment hasn't happened yet
3870    assert_eq!(undeclared.len(), 1);
3871    assert_eq!(undeclared[0].name, "http_client");
3872    assert_eq!(undeclared[0].line, 3);
3873}
3874
3875#[test]
3876fn test_no_false_positive_for_later_assignment() {
3877    // This is the actual bug we fixed - make sure local assignment later in function
3878    // doesn't prevent detection of undeclared fixture usage BEFORE the assignment
3879    let db = FixtureDatabase::new();
3880
3881    let conftest_content = r#"import pytest
3882
3883@pytest.fixture
3884def http_client():
3885    return "fixture"
3886"#;
3887    let conftest_path = PathBuf::from("/tmp/conftest.py");
3888    db.analyze_file(conftest_path.clone(), conftest_content);
3889
3890    // This was the original issue: http_client used on line 2, but assigned on line 4
3891    // Old code would see the assignment and not flag line 2
3892    let test_content = r#"async def test_example():
3893    result = await http_client.get("/api")  # Should be flagged
3894    # Now assign locally
3895    http_client = "local"
3896    # This should NOT be flagged because variable is now assigned
3897    result2 = http_client
3898"#;
3899    let test_path = PathBuf::from("/tmp/test_example.py");
3900    db.analyze_file(test_path.clone(), test_content);
3901
3902    let undeclared = db.get_undeclared_fixtures(&test_path);
3903
3904    // Should only detect one undeclared usage (line 2)
3905    assert_eq!(
3906        undeclared.len(),
3907        1,
3908        "Should detect exactly one undeclared fixture"
3909    );
3910    assert_eq!(undeclared[0].name, "http_client");
3911    assert_eq!(
3912        undeclared[0].line, 2,
3913        "Should flag usage on line 2 before assignment on line 4"
3914    );
3915}
3916
3917#[test]
3918fn test_fixture_resolution_priority_deterministic() {
3919    // Test that fixture resolution is deterministic and follows priority rules
3920    // This test ensures we don't randomly pick a definition from DashMap iteration
3921    let db = FixtureDatabase::new();
3922
3923    // Create multiple conftest.py files with the same fixture name in different locations
3924    // Scenario: /tmp/project/app/tests/test_foo.py should resolve to closest conftest
3925
3926    // Root conftest
3927    let root_content = r#"
3928import pytest
3929
3930@pytest.fixture
3931def db():
3932    return "root_db"
3933"#;
3934    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
3935    db.analyze_file(root_conftest.clone(), root_content);
3936
3937    // Unrelated conftest (different branch of directory tree)
3938    let unrelated_content = r#"
3939import pytest
3940
3941@pytest.fixture
3942def db():
3943    return "unrelated_db"
3944"#;
3945    let unrelated_conftest = PathBuf::from("/tmp/other/conftest.py");
3946    db.analyze_file(unrelated_conftest.clone(), unrelated_content);
3947
3948    // App-level conftest
3949    let app_content = r#"
3950import pytest
3951
3952@pytest.fixture
3953def db():
3954    return "app_db"
3955"#;
3956    let app_conftest = PathBuf::from("/tmp/project/app/conftest.py");
3957    db.analyze_file(app_conftest.clone(), app_content);
3958
3959    // Tests-level conftest (closest)
3960    let tests_content = r#"
3961import pytest
3962
3963@pytest.fixture
3964def db():
3965    return "tests_db"
3966"#;
3967    let tests_conftest = PathBuf::from("/tmp/project/app/tests/conftest.py");
3968    db.analyze_file(tests_conftest.clone(), tests_content);
3969
3970    // Test file
3971    let test_content = r#"
3972def test_database(db):
3973    assert db is not None
3974"#;
3975    let test_path = PathBuf::from("/tmp/project/app/tests/test_foo.py");
3976    db.analyze_file(test_path.clone(), test_content);
3977
3978    // Run the resolution multiple times to ensure it's deterministic
3979    for iteration in 0..10 {
3980        let result = db.find_fixture_definition(&test_path, 1, 18); // Line 2, column 18 = "db" parameter
3981
3982        assert!(
3983            result.is_some(),
3984            "Iteration {}: Should find a fixture definition",
3985            iteration
3986        );
3987
3988        let def = result.unwrap();
3989        assert_eq!(
3990            def.name, "db",
3991            "Iteration {}: Should find 'db' fixture",
3992            iteration
3993        );
3994
3995        // Should ALWAYS resolve to the closest conftest.py (tests_conftest)
3996        assert_eq!(
3997            def.file_path, tests_conftest,
3998            "Iteration {}: Should consistently resolve to closest conftest.py at {:?}, but got {:?}",
3999            iteration,
4000            tests_conftest,
4001            def.file_path
4002        );
4003    }
4004}
4005
4006#[test]
4007fn test_fixture_resolution_prefers_parent_over_unrelated() {
4008    // Test that when no fixture is in same file or conftest hierarchy,
4009    // we prefer third-party fixtures (site-packages) over random unrelated conftest files
4010    let db = FixtureDatabase::new();
4011
4012    // Unrelated conftest in different directory tree
4013    let unrelated_content = r#"
4014import pytest
4015
4016@pytest.fixture
4017def custom_fixture():
4018    return "unrelated"
4019"#;
4020    let unrelated_conftest = PathBuf::from("/tmp/other_project/conftest.py");
4021    db.analyze_file(unrelated_conftest.clone(), unrelated_content);
4022
4023    // Third-party fixture (mock in site-packages)
4024    let third_party_content = r#"
4025import pytest
4026
4027@pytest.fixture
4028def custom_fixture():
4029    return "third_party"
4030"#;
4031    let third_party_path =
4032        PathBuf::from("/tmp/.venv/lib/python3.11/site-packages/pytest_custom/plugin.py");
4033    db.analyze_file(third_party_path.clone(), third_party_content);
4034
4035    // Test file in a different project
4036    let test_content = r#"
4037def test_custom(custom_fixture):
4038    assert custom_fixture is not None
4039"#;
4040    let test_path = PathBuf::from("/tmp/my_project/test_foo.py");
4041    db.analyze_file(test_path.clone(), test_content);
4042
4043    // Should prefer third-party fixture over unrelated conftest
4044    let result = db.find_fixture_definition(&test_path, 1, 16);
4045    assert!(result.is_some());
4046    let def = result.unwrap();
4047
4048    // Should be the third-party fixture (site-packages)
4049    assert_eq!(
4050        def.file_path, third_party_path,
4051        "Should prefer third-party fixture from site-packages over unrelated conftest.py"
4052    );
4053}
4054
4055#[test]
4056fn test_fixture_resolution_hierarchy_over_third_party() {
4057    // Test that fixtures in the conftest hierarchy are preferred over third-party
4058    let db = FixtureDatabase::new();
4059
4060    // Third-party fixture
4061    let third_party_content = r#"
4062import pytest
4063
4064@pytest.fixture
4065def mocker():
4066    return "third_party_mocker"
4067"#;
4068    let third_party_path =
4069        PathBuf::from("/tmp/project/.venv/lib/python3.11/site-packages/pytest_mock/plugin.py");
4070    db.analyze_file(third_party_path.clone(), third_party_content);
4071
4072    // Local conftest.py that overrides mocker
4073    let local_content = r#"
4074import pytest
4075
4076@pytest.fixture
4077def mocker():
4078    return "local_mocker"
4079"#;
4080    let local_conftest = PathBuf::from("/tmp/project/conftest.py");
4081    db.analyze_file(local_conftest.clone(), local_content);
4082
4083    // Test file
4084    let test_content = r#"
4085def test_mocking(mocker):
4086    assert mocker is not None
4087"#;
4088    let test_path = PathBuf::from("/tmp/project/test_foo.py");
4089    db.analyze_file(test_path.clone(), test_content);
4090
4091    // Should prefer local conftest over third-party
4092    let result = db.find_fixture_definition(&test_path, 1, 17);
4093    assert!(result.is_some());
4094    let def = result.unwrap();
4095
4096    assert_eq!(
4097        def.file_path, local_conftest,
4098        "Should prefer local conftest.py fixture over third-party fixture"
4099    );
4100}
4101
4102#[test]
4103fn test_fixture_resolution_with_relative_paths() {
4104    // Test that fixture resolution works even when paths are stored with different representations
4105    // This simulates the case where analyze_file is called with relative paths vs absolute paths
4106    let db = FixtureDatabase::new();
4107
4108    // Conftest with absolute path
4109    let conftest_content = r#"
4110import pytest
4111
4112@pytest.fixture
4113def shared():
4114    return "conftest"
4115"#;
4116    let conftest_abs = PathBuf::from("/tmp/project/tests/conftest.py");
4117    db.analyze_file(conftest_abs.clone(), conftest_content);
4118
4119    // Test file also with absolute path
4120    let test_content = r#"
4121def test_example(shared):
4122    assert shared == "conftest"
4123"#;
4124    let test_abs = PathBuf::from("/tmp/project/tests/test_foo.py");
4125    db.analyze_file(test_abs.clone(), test_content);
4126
4127    // Should find the fixture from conftest
4128    let result = db.find_fixture_definition(&test_abs, 1, 17);
4129    assert!(result.is_some(), "Should find fixture with absolute paths");
4130    let def = result.unwrap();
4131    assert_eq!(def.file_path, conftest_abs, "Should resolve to conftest.py");
4132}
4133
4134#[test]
4135fn test_fixture_resolution_deep_hierarchy() {
4136    // Test resolution in a deep directory hierarchy to ensure path traversal works correctly
4137    let db = FixtureDatabase::new();
4138
4139    // Root level fixture
4140    let root_content = r#"
4141import pytest
4142
4143@pytest.fixture
4144def db():
4145    return "root"
4146"#;
4147    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4148    db.analyze_file(root_conftest.clone(), root_content);
4149
4150    // Level 1
4151    let level1_content = r#"
4152import pytest
4153
4154@pytest.fixture
4155def db():
4156    return "level1"
4157"#;
4158    let level1_conftest = PathBuf::from("/tmp/project/src/conftest.py");
4159    db.analyze_file(level1_conftest.clone(), level1_content);
4160
4161    // Level 2
4162    let level2_content = r#"
4163import pytest
4164
4165@pytest.fixture
4166def db():
4167    return "level2"
4168"#;
4169    let level2_conftest = PathBuf::from("/tmp/project/src/app/conftest.py");
4170    db.analyze_file(level2_conftest.clone(), level2_content);
4171
4172    // Level 3 - deepest
4173    let level3_content = r#"
4174import pytest
4175
4176@pytest.fixture
4177def db():
4178    return "level3"
4179"#;
4180    let level3_conftest = PathBuf::from("/tmp/project/src/app/tests/conftest.py");
4181    db.analyze_file(level3_conftest.clone(), level3_content);
4182
4183    // Test at level 3 - should use level 3 fixture
4184    let test_l3_content = r#"
4185def test_db(db):
4186    assert db == "level3"
4187"#;
4188    let test_l3 = PathBuf::from("/tmp/project/src/app/tests/test_foo.py");
4189    db.analyze_file(test_l3.clone(), test_l3_content);
4190
4191    let result_l3 = db.find_fixture_definition(&test_l3, 1, 12);
4192    assert!(result_l3.is_some());
4193    assert_eq!(
4194        result_l3.unwrap().file_path,
4195        level3_conftest,
4196        "Test at level 3 should use level 3 fixture"
4197    );
4198
4199    // Test at level 2 - should use level 2 fixture
4200    let test_l2_content = r#"
4201def test_db(db):
4202    assert db == "level2"
4203"#;
4204    let test_l2 = PathBuf::from("/tmp/project/src/app/test_bar.py");
4205    db.analyze_file(test_l2.clone(), test_l2_content);
4206
4207    let result_l2 = db.find_fixture_definition(&test_l2, 1, 12);
4208    assert!(result_l2.is_some());
4209    assert_eq!(
4210        result_l2.unwrap().file_path,
4211        level2_conftest,
4212        "Test at level 2 should use level 2 fixture"
4213    );
4214
4215    // Test at level 1 - should use level 1 fixture
4216    let test_l1_content = r#"
4217def test_db(db):
4218    assert db == "level1"
4219"#;
4220    let test_l1 = PathBuf::from("/tmp/project/src/test_baz.py");
4221    db.analyze_file(test_l1.clone(), test_l1_content);
4222
4223    let result_l1 = db.find_fixture_definition(&test_l1, 1, 12);
4224    assert!(result_l1.is_some());
4225    assert_eq!(
4226        result_l1.unwrap().file_path,
4227        level1_conftest,
4228        "Test at level 1 should use level 1 fixture"
4229    );
4230
4231    // Test at root - should use root fixture
4232    let test_root_content = r#"
4233def test_db(db):
4234    assert db == "root"
4235"#;
4236    let test_root = PathBuf::from("/tmp/project/test_root.py");
4237    db.analyze_file(test_root.clone(), test_root_content);
4238
4239    let result_root = db.find_fixture_definition(&test_root, 1, 12);
4240    assert!(result_root.is_some());
4241    assert_eq!(
4242        result_root.unwrap().file_path,
4243        root_conftest,
4244        "Test at root should use root fixture"
4245    );
4246}
4247
4248#[test]
4249fn test_fixture_resolution_sibling_directories() {
4250    // Test that fixtures in sibling directories don't leak into each other
4251    let db = FixtureDatabase::new();
4252
4253    // Root conftest
4254    let root_content = r#"
4255import pytest
4256
4257@pytest.fixture
4258def shared():
4259    return "root"
4260"#;
4261    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4262    db.analyze_file(root_conftest.clone(), root_content);
4263
4264    // Module A with its own fixture
4265    let module_a_content = r#"
4266import pytest
4267
4268@pytest.fixture
4269def module_specific():
4270    return "module_a"
4271"#;
4272    let module_a_conftest = PathBuf::from("/tmp/project/module_a/conftest.py");
4273    db.analyze_file(module_a_conftest.clone(), module_a_content);
4274
4275    // Module B with its own fixture (same name!)
4276    let module_b_content = r#"
4277import pytest
4278
4279@pytest.fixture
4280def module_specific():
4281    return "module_b"
4282"#;
4283    let module_b_conftest = PathBuf::from("/tmp/project/module_b/conftest.py");
4284    db.analyze_file(module_b_conftest.clone(), module_b_content);
4285
4286    // Test in module A - should use module A's fixture
4287    let test_a_content = r#"
4288def test_a(module_specific, shared):
4289    assert module_specific == "module_a"
4290    assert shared == "root"
4291"#;
4292    let test_a = PathBuf::from("/tmp/project/module_a/test_a.py");
4293    db.analyze_file(test_a.clone(), test_a_content);
4294
4295    let result_a = db.find_fixture_definition(&test_a, 1, 11);
4296    assert!(result_a.is_some());
4297    assert_eq!(
4298        result_a.unwrap().file_path,
4299        module_a_conftest,
4300        "Test in module_a should use module_a's fixture"
4301    );
4302
4303    // Test in module B - should use module B's fixture
4304    let test_b_content = r#"
4305def test_b(module_specific, shared):
4306    assert module_specific == "module_b"
4307    assert shared == "root"
4308"#;
4309    let test_b = PathBuf::from("/tmp/project/module_b/test_b.py");
4310    db.analyze_file(test_b.clone(), test_b_content);
4311
4312    let result_b = db.find_fixture_definition(&test_b, 1, 11);
4313    assert!(result_b.is_some());
4314    assert_eq!(
4315        result_b.unwrap().file_path,
4316        module_b_conftest,
4317        "Test in module_b should use module_b's fixture"
4318    );
4319
4320    // Both should be able to access shared root fixture
4321    // "shared" starts at column 29 (after "module_specific, ")
4322    let result_a_shared = db.find_fixture_definition(&test_a, 1, 29);
4323    assert!(result_a_shared.is_some());
4324    assert_eq!(
4325        result_a_shared.unwrap().file_path,
4326        root_conftest,
4327        "Test in module_a should access root's shared fixture"
4328    );
4329
4330    let result_b_shared = db.find_fixture_definition(&test_b, 1, 29);
4331    assert!(result_b_shared.is_some());
4332    assert_eq!(
4333        result_b_shared.unwrap().file_path,
4334        root_conftest,
4335        "Test in module_b should access root's shared fixture"
4336    );
4337}
4338
4339#[test]
4340fn test_fixture_resolution_multiple_unrelated_branches_is_deterministic() {
4341    // This is the key test: when a fixture is defined in multiple unrelated branches,
4342    // the resolution should be deterministic (not random based on DashMap iteration)
4343    let db = FixtureDatabase::new();
4344
4345    // Three unrelated project branches
4346    let branch_a_content = r#"
4347import pytest
4348
4349@pytest.fixture
4350def common_fixture():
4351    return "branch_a"
4352"#;
4353    let branch_a_conftest = PathBuf::from("/tmp/projects/project_a/conftest.py");
4354    db.analyze_file(branch_a_conftest.clone(), branch_a_content);
4355
4356    let branch_b_content = r#"
4357import pytest
4358
4359@pytest.fixture
4360def common_fixture():
4361    return "branch_b"
4362"#;
4363    let branch_b_conftest = PathBuf::from("/tmp/projects/project_b/conftest.py");
4364    db.analyze_file(branch_b_conftest.clone(), branch_b_content);
4365
4366    let branch_c_content = r#"
4367import pytest
4368
4369@pytest.fixture
4370def common_fixture():
4371    return "branch_c"
4372"#;
4373    let branch_c_conftest = PathBuf::from("/tmp/projects/project_c/conftest.py");
4374    db.analyze_file(branch_c_conftest.clone(), branch_c_content);
4375
4376    // Test in yet another unrelated location
4377    let test_content = r#"
4378def test_something(common_fixture):
4379    assert common_fixture is not None
4380"#;
4381    let test_path = PathBuf::from("/tmp/unrelated/test_foo.py");
4382    db.analyze_file(test_path.clone(), test_content);
4383
4384    // Run resolution multiple times - should always return the same result
4385    let mut results = Vec::new();
4386    for _ in 0..20 {
4387        let result = db.find_fixture_definition(&test_path, 1, 19);
4388        assert!(result.is_some(), "Should find a fixture");
4389        results.push(result.unwrap().file_path.clone());
4390    }
4391
4392    // All results should be identical (deterministic)
4393    let first_result = &results[0];
4394    for (i, result) in results.iter().enumerate() {
4395        assert_eq!(
4396            result, first_result,
4397            "Iteration {}: fixture resolution should be deterministic, expected {:?} but got {:?}",
4398            i, first_result, result
4399        );
4400    }
4401}
4402
4403#[test]
4404fn test_fixture_resolution_conftest_at_various_depths() {
4405    // Test that conftest.py files at different depths are correctly prioritized
4406    let db = FixtureDatabase::new();
4407
4408    // Deep conftest
4409    let deep_content = r#"
4410import pytest
4411
4412@pytest.fixture
4413def fixture_a():
4414    return "deep"
4415
4416@pytest.fixture
4417def fixture_b():
4418    return "deep"
4419"#;
4420    let deep_conftest = PathBuf::from("/tmp/project/src/module/tests/integration/conftest.py");
4421    db.analyze_file(deep_conftest.clone(), deep_content);
4422
4423    // Mid-level conftest - overrides fixture_a but not fixture_b
4424    let mid_content = r#"
4425import pytest
4426
4427@pytest.fixture
4428def fixture_a():
4429    return "mid"
4430"#;
4431    let mid_conftest = PathBuf::from("/tmp/project/src/module/conftest.py");
4432    db.analyze_file(mid_conftest.clone(), mid_content);
4433
4434    // Root conftest - defines fixture_c
4435    let root_content = r#"
4436import pytest
4437
4438@pytest.fixture
4439def fixture_c():
4440    return "root"
4441"#;
4442    let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4443    db.analyze_file(root_conftest.clone(), root_content);
4444
4445    // Test in deep directory
4446    let test_content = r#"
4447def test_all(fixture_a, fixture_b, fixture_c):
4448    assert fixture_a == "deep"
4449    assert fixture_b == "deep"
4450    assert fixture_c == "root"
4451"#;
4452    let test_path = PathBuf::from("/tmp/project/src/module/tests/integration/test_foo.py");
4453    db.analyze_file(test_path.clone(), test_content);
4454
4455    // fixture_a: should resolve to deep (closest)
4456    let result_a = db.find_fixture_definition(&test_path, 1, 13);
4457    assert!(result_a.is_some());
4458    assert_eq!(
4459        result_a.unwrap().file_path,
4460        deep_conftest,
4461        "fixture_a should resolve to closest conftest (deep)"
4462    );
4463
4464    // fixture_b: should resolve to deep (only defined there)
4465    let result_b = db.find_fixture_definition(&test_path, 1, 24);
4466    assert!(result_b.is_some());
4467    assert_eq!(
4468        result_b.unwrap().file_path,
4469        deep_conftest,
4470        "fixture_b should resolve to deep conftest"
4471    );
4472
4473    // fixture_c: should resolve to root (only defined there)
4474    let result_c = db.find_fixture_definition(&test_path, 1, 35);
4475    assert!(result_c.is_some());
4476    assert_eq!(
4477        result_c.unwrap().file_path,
4478        root_conftest,
4479        "fixture_c should resolve to root conftest"
4480    );
4481
4482    // Test in mid-level directory (one level up)
4483    let test_mid_content = r#"
4484def test_mid(fixture_a, fixture_c):
4485    assert fixture_a == "mid"
4486    assert fixture_c == "root"
4487"#;
4488    let test_mid_path = PathBuf::from("/tmp/project/src/module/test_bar.py");
4489    db.analyze_file(test_mid_path.clone(), test_mid_content);
4490
4491    // fixture_a from mid-level: should resolve to mid conftest
4492    let result_a_mid = db.find_fixture_definition(&test_mid_path, 1, 13);
4493    assert!(result_a_mid.is_some());
4494    assert_eq!(
4495        result_a_mid.unwrap().file_path,
4496        mid_conftest,
4497        "fixture_a from mid-level test should resolve to mid conftest"
4498    );
4499}