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 (mainly for testing)
44    file_cache: Arc<DashMap<PathBuf, 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        debug!("Analyzing file: {:?}", file_path);
266
267        // Cache the file content for later use (e.g., in find_fixture_definition)
268        self.file_cache
269            .insert(file_path.clone(), content.to_string());
270
271        // Parse the Python code
272        let parsed = match parse(content, Mode::Module, "") {
273            Ok(ast) => ast,
274            Err(e) => {
275                warn!("Failed to parse {:?}: {:?}", file_path, e);
276                return;
277            }
278        };
279
280        // Clear previous usages for this file
281        self.usages.remove(&file_path);
282
283        // Clear previous undeclared fixtures for this file
284        self.undeclared_fixtures.remove(&file_path);
285
286        // Clear previous imports for this file
287        self.imports.remove(&file_path);
288
289        // Clear previous fixture definitions from this file
290        // We need to remove definitions that were in this file
291        for mut entry in self.definitions.iter_mut() {
292            entry.value_mut().retain(|def| def.file_path != file_path);
293        }
294        // Remove empty entries
295        self.definitions.retain(|_, defs| !defs.is_empty());
296
297        // Check if this is a conftest.py
298        let is_conftest = file_path
299            .file_name()
300            .map(|n| n == "conftest.py")
301            .unwrap_or(false);
302        debug!("is_conftest: {}", is_conftest);
303
304        // Process each statement in the module
305        if let rustpython_parser::ast::Mod::Module(module) = parsed {
306            debug!("Module has {} statements", module.body.len());
307
308            // First pass: collect all module-level names (imports, assignments, function/class defs)
309            let mut module_level_names = std::collections::HashSet::new();
310            for stmt in &module.body {
311                self.collect_module_level_names(stmt, &mut module_level_names);
312            }
313            self.imports.insert(file_path.clone(), module_level_names);
314
315            // Second pass: analyze fixtures and tests
316            for stmt in &module.body {
317                self.visit_stmt(stmt, &file_path, is_conftest, content);
318            }
319        }
320
321        debug!("Analysis complete for {:?}", file_path);
322    }
323
324    fn visit_stmt(&self, stmt: &Stmt, file_path: &PathBuf, _is_conftest: bool, content: &str) {
325        // First check for assignment-style fixtures: fixture_name = pytest.fixture()(func)
326        if let Stmt::Assign(assign) = stmt {
327            self.visit_assignment_fixture(assign, file_path, content);
328        }
329
330        // Handle both regular and async function definitions
331        let (func_name, decorator_list, args, range, body) = match stmt {
332            Stmt::FunctionDef(func_def) => (
333                func_def.name.as_str(),
334                &func_def.decorator_list,
335                &func_def.args,
336                func_def.range,
337                &func_def.body,
338            ),
339            Stmt::AsyncFunctionDef(func_def) => (
340                func_def.name.as_str(),
341                &func_def.decorator_list,
342                &func_def.args,
343                func_def.range,
344                &func_def.body,
345            ),
346            _ => return,
347        };
348
349        debug!("Found function: {}", func_name);
350
351        // Check if this is a fixture definition
352        debug!(
353            "Function {} has {} decorators",
354            func_name,
355            decorator_list.len()
356        );
357        let is_fixture = decorator_list.iter().any(|dec| {
358            let result = Self::is_fixture_decorator(dec);
359            if result {
360                debug!("  Decorator matched as fixture!");
361            }
362            result
363        });
364
365        if is_fixture {
366            // Calculate line number from the range start
367            let line = self.get_line_from_offset(range.start().to_usize(), content);
368
369            // Extract docstring if present
370            let docstring = self.extract_docstring(body);
371
372            info!(
373                "Found fixture definition: {} at {:?}:{}",
374                func_name, file_path, line
375            );
376            if let Some(ref doc) = docstring {
377                debug!("  Docstring: {}", doc);
378            }
379
380            let definition = FixtureDefinition {
381                name: func_name.to_string(),
382                file_path: file_path.clone(),
383                line,
384                docstring,
385            };
386
387            self.definitions
388                .entry(func_name.to_string())
389                .or_default()
390                .push(definition);
391
392            // Fixtures can depend on other fixtures - record these as usages too
393            let mut declared_params: std::collections::HashSet<String> =
394                std::collections::HashSet::new();
395            declared_params.insert("self".to_string());
396            declared_params.insert("request".to_string());
397            declared_params.insert(func_name.to_string()); // Exclude function name itself
398
399            for arg in &args.args {
400                let arg_name = arg.def.arg.as_str();
401                declared_params.insert(arg_name.to_string());
402
403                if arg_name != "self" && arg_name != "request" {
404                    // Get the actual line where this parameter appears
405                    // arg.def.range contains the location of the parameter name
406                    let arg_line =
407                        self.get_line_from_offset(arg.def.range.start().to_usize(), content);
408                    let start_char = self
409                        .get_char_position_from_offset(arg.def.range.start().to_usize(), content);
410                    let end_char =
411                        self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
412
413                    info!(
414                        "Found fixture dependency: {} at {:?}:{}:{}",
415                        arg_name, file_path, arg_line, start_char
416                    );
417
418                    let usage = FixtureUsage {
419                        name: arg_name.to_string(),
420                        file_path: file_path.clone(),
421                        line: arg_line, // Use actual parameter line
422                        start_char,
423                        end_char,
424                    };
425
426                    self.usages
427                        .entry(file_path.clone())
428                        .or_default()
429                        .push(usage);
430                }
431            }
432
433            // Scan fixture body for undeclared fixture usages
434            let function_line = self.get_line_from_offset(range.start().to_usize(), content);
435            self.scan_function_body_for_undeclared_fixtures(
436                body,
437                file_path,
438                content,
439                &declared_params,
440                func_name,
441                function_line,
442            );
443        }
444
445        // Check if this is a test function
446        let is_test = func_name.starts_with("test_");
447
448        if is_test {
449            debug!("Found test function: {}", func_name);
450
451            // Collect declared parameters
452            let mut declared_params: std::collections::HashSet<String> =
453                std::collections::HashSet::new();
454            declared_params.insert("self".to_string());
455            declared_params.insert("request".to_string()); // pytest built-in
456
457            // Extract fixture usages from function parameters
458            for arg in &args.args {
459                let arg_name = arg.def.arg.as_str();
460                declared_params.insert(arg_name.to_string());
461
462                if arg_name != "self" {
463                    // Get the actual line where this parameter appears
464                    // This handles multiline function signatures correctly
465                    // arg.def.range contains the location of the parameter name
466                    let arg_offset = arg.def.range.start().to_usize();
467                    let arg_line = self.get_line_from_offset(arg_offset, content);
468                    let start_char = self.get_char_position_from_offset(arg_offset, content);
469                    let end_char =
470                        self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
471
472                    debug!(
473                        "Parameter {} at offset {}, calculated line {}, char {}",
474                        arg_name, arg_offset, arg_line, start_char
475                    );
476                    info!(
477                        "Found fixture usage: {} at {:?}:{}:{}",
478                        arg_name, file_path, arg_line, start_char
479                    );
480
481                    let usage = FixtureUsage {
482                        name: arg_name.to_string(),
483                        file_path: file_path.clone(),
484                        line: arg_line, // Use actual parameter line
485                        start_char,
486                        end_char,
487                    };
488
489                    // Append to existing usages for this file
490                    self.usages
491                        .entry(file_path.clone())
492                        .or_default()
493                        .push(usage);
494                }
495            }
496
497            // Now scan the function body for undeclared fixture usages
498            let function_line = self.get_line_from_offset(range.start().to_usize(), content);
499            self.scan_function_body_for_undeclared_fixtures(
500                body,
501                file_path,
502                content,
503                &declared_params,
504                func_name,
505                function_line,
506            );
507        }
508    }
509
510    fn visit_assignment_fixture(
511        &self,
512        assign: &rustpython_parser::ast::StmtAssign,
513        file_path: &PathBuf,
514        content: &str,
515    ) {
516        // Check for pattern: fixture_name = pytest.fixture()(func)
517        // The value should be a Call expression where the func is a Call to pytest.fixture()
518
519        if let Expr::Call(outer_call) = &*assign.value {
520            // Check if outer_call.func is pytest.fixture() or fixture()
521            if let Expr::Call(inner_call) = &*outer_call.func {
522                if Self::is_fixture_decorator(&inner_call.func) {
523                    // This is pytest.fixture()(something)
524                    // Get the fixture name from the assignment target
525                    for target in &assign.targets {
526                        if let Expr::Name(name) = target {
527                            let fixture_name = name.id.as_str();
528                            let line =
529                                self.get_line_from_offset(assign.range.start().to_usize(), content);
530
531                            info!(
532                                "Found fixture assignment: {} at {:?}:{}",
533                                fixture_name, file_path, line
534                            );
535
536                            // We don't have a docstring for assignment-style fixtures
537                            let definition = FixtureDefinition {
538                                name: fixture_name.to_string(),
539                                file_path: file_path.clone(),
540                                line,
541                                docstring: None,
542                            };
543
544                            self.definitions
545                                .entry(fixture_name.to_string())
546                                .or_default()
547                                .push(definition);
548                        }
549                    }
550                }
551            }
552        }
553    }
554
555    fn is_fixture_decorator(expr: &Expr) -> bool {
556        match expr {
557            Expr::Name(name) => name.id.as_str() == "fixture",
558            Expr::Attribute(attr) => {
559                // Check for pytest.fixture
560                if let Expr::Name(value) = &*attr.value {
561                    value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
562                } else {
563                    false
564                }
565            }
566            Expr::Call(call) => {
567                // Handle @pytest.fixture() or @fixture() with parentheses
568                Self::is_fixture_decorator(&call.func)
569            }
570            _ => false,
571        }
572    }
573
574    fn scan_function_body_for_undeclared_fixtures(
575        &self,
576        body: &[Stmt],
577        file_path: &PathBuf,
578        content: &str,
579        declared_params: &std::collections::HashSet<String>,
580        function_name: &str,
581        function_line: usize,
582    ) {
583        // First, collect all local variable names with their definition line numbers
584        let mut local_vars = std::collections::HashMap::new();
585        self.collect_local_variables(body, content, &mut local_vars);
586
587        // Also add imported names to local_vars (they shouldn't be flagged as undeclared fixtures)
588        // Set their line to 0 so they're always considered "in scope"
589        if let Some(imports) = self.imports.get(file_path) {
590            for import in imports.iter() {
591                local_vars.insert(import.clone(), 0);
592            }
593        }
594
595        // Walk through the function body and find all Name references
596        for stmt in body {
597            self.visit_stmt_for_names(
598                stmt,
599                file_path,
600                content,
601                declared_params,
602                &local_vars,
603                function_name,
604                function_line,
605            );
606        }
607    }
608
609    fn collect_module_level_names(
610        &self,
611        stmt: &Stmt,
612        names: &mut std::collections::HashSet<String>,
613    ) {
614        match stmt {
615            // Imports
616            Stmt::Import(import_stmt) => {
617                for alias in &import_stmt.names {
618                    // If there's an "as" alias, use that; otherwise use the original name
619                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
620                    names.insert(name.to_string());
621                }
622            }
623            Stmt::ImportFrom(import_from) => {
624                for alias in &import_from.names {
625                    // If there's an "as" alias, use that; otherwise use the original name
626                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
627                    names.insert(name.to_string());
628                }
629            }
630            // Regular function definitions (not fixtures)
631            Stmt::FunctionDef(func_def) => {
632                // Check if this is NOT a fixture
633                let is_fixture = func_def
634                    .decorator_list
635                    .iter()
636                    .any(Self::is_fixture_decorator);
637                if !is_fixture {
638                    names.insert(func_def.name.to_string());
639                }
640            }
641            // Async function definitions (not fixtures)
642            Stmt::AsyncFunctionDef(func_def) => {
643                let is_fixture = func_def
644                    .decorator_list
645                    .iter()
646                    .any(Self::is_fixture_decorator);
647                if !is_fixture {
648                    names.insert(func_def.name.to_string());
649                }
650            }
651            // Class definitions
652            Stmt::ClassDef(class_def) => {
653                names.insert(class_def.name.to_string());
654            }
655            // Module-level assignments
656            Stmt::Assign(assign) => {
657                for target in &assign.targets {
658                    self.collect_names_from_expr(target, names);
659                }
660            }
661            Stmt::AnnAssign(ann_assign) => {
662                self.collect_names_from_expr(&ann_assign.target, names);
663            }
664            _ => {}
665        }
666    }
667
668    fn collect_local_variables(
669        &self,
670        body: &[Stmt],
671        content: &str,
672        local_vars: &mut std::collections::HashMap<String, usize>,
673    ) {
674        for stmt in body {
675            match stmt {
676                Stmt::Assign(assign) => {
677                    // Collect variable names from left-hand side with their line numbers
678                    let line = self.get_line_from_offset(assign.range.start().to_usize(), content);
679                    let mut temp_names = std::collections::HashSet::new();
680                    for target in &assign.targets {
681                        self.collect_names_from_expr(target, &mut temp_names);
682                    }
683                    for name in temp_names {
684                        local_vars.insert(name, line);
685                    }
686                }
687                Stmt::AnnAssign(ann_assign) => {
688                    // Collect annotated assignment targets with their line numbers
689                    let line =
690                        self.get_line_from_offset(ann_assign.range.start().to_usize(), content);
691                    let mut temp_names = std::collections::HashSet::new();
692                    self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
693                    for name in temp_names {
694                        local_vars.insert(name, line);
695                    }
696                }
697                Stmt::AugAssign(aug_assign) => {
698                    // Collect augmented assignment targets (+=, -=, etc.)
699                    let line =
700                        self.get_line_from_offset(aug_assign.range.start().to_usize(), content);
701                    let mut temp_names = std::collections::HashSet::new();
702                    self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
703                    for name in temp_names {
704                        local_vars.insert(name, line);
705                    }
706                }
707                Stmt::For(for_stmt) => {
708                    // Collect loop variable with its line number
709                    let line =
710                        self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
711                    let mut temp_names = std::collections::HashSet::new();
712                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
713                    for name in temp_names {
714                        local_vars.insert(name, line);
715                    }
716                    // Recursively collect from body
717                    self.collect_local_variables(&for_stmt.body, content, local_vars);
718                }
719                Stmt::AsyncFor(for_stmt) => {
720                    let line =
721                        self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
722                    let mut temp_names = std::collections::HashSet::new();
723                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
724                    for name in temp_names {
725                        local_vars.insert(name, line);
726                    }
727                    self.collect_local_variables(&for_stmt.body, content, local_vars);
728                }
729                Stmt::While(while_stmt) => {
730                    self.collect_local_variables(&while_stmt.body, content, local_vars);
731                }
732                Stmt::If(if_stmt) => {
733                    self.collect_local_variables(&if_stmt.body, content, local_vars);
734                    self.collect_local_variables(&if_stmt.orelse, content, local_vars);
735                }
736                Stmt::With(with_stmt) => {
737                    // Collect context manager variables with their line numbers
738                    let line =
739                        self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
740                    for item in &with_stmt.items {
741                        if let Some(ref optional_vars) = item.optional_vars {
742                            let mut temp_names = std::collections::HashSet::new();
743                            self.collect_names_from_expr(optional_vars, &mut temp_names);
744                            for name in temp_names {
745                                local_vars.insert(name, line);
746                            }
747                        }
748                    }
749                    self.collect_local_variables(&with_stmt.body, content, local_vars);
750                }
751                Stmt::AsyncWith(with_stmt) => {
752                    let line =
753                        self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
754                    for item in &with_stmt.items {
755                        if let Some(ref optional_vars) = item.optional_vars {
756                            let mut temp_names = std::collections::HashSet::new();
757                            self.collect_names_from_expr(optional_vars, &mut temp_names);
758                            for name in temp_names {
759                                local_vars.insert(name, line);
760                            }
761                        }
762                    }
763                    self.collect_local_variables(&with_stmt.body, content, local_vars);
764                }
765                Stmt::Try(try_stmt) => {
766                    self.collect_local_variables(&try_stmt.body, content, local_vars);
767                    // Note: ExceptHandler struct doesn't expose name/body in current API
768                    // This is a limitation of rustpython-parser 0.4.0
769                    self.collect_local_variables(&try_stmt.orelse, content, local_vars);
770                    self.collect_local_variables(&try_stmt.finalbody, content, local_vars);
771                }
772                _ => {}
773            }
774        }
775    }
776
777    #[allow(clippy::only_used_in_recursion)]
778    fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
779        match expr {
780            Expr::Name(name) => {
781                names.insert(name.id.to_string());
782            }
783            Expr::Tuple(tuple) => {
784                for elt in &tuple.elts {
785                    self.collect_names_from_expr(elt, names);
786                }
787            }
788            Expr::List(list) => {
789                for elt in &list.elts {
790                    self.collect_names_from_expr(elt, names);
791                }
792            }
793            _ => {}
794        }
795    }
796
797    #[allow(clippy::too_many_arguments)]
798    fn visit_stmt_for_names(
799        &self,
800        stmt: &Stmt,
801        file_path: &PathBuf,
802        content: &str,
803        declared_params: &std::collections::HashSet<String>,
804        local_vars: &std::collections::HashMap<String, usize>,
805        function_name: &str,
806        function_line: usize,
807    ) {
808        match stmt {
809            Stmt::Expr(expr_stmt) => {
810                self.visit_expr_for_names(
811                    &expr_stmt.value,
812                    file_path,
813                    content,
814                    declared_params,
815                    local_vars,
816                    function_name,
817                    function_line,
818                );
819            }
820            Stmt::Assign(assign) => {
821                self.visit_expr_for_names(
822                    &assign.value,
823                    file_path,
824                    content,
825                    declared_params,
826                    local_vars,
827                    function_name,
828                    function_line,
829                );
830            }
831            Stmt::AugAssign(aug_assign) => {
832                self.visit_expr_for_names(
833                    &aug_assign.value,
834                    file_path,
835                    content,
836                    declared_params,
837                    local_vars,
838                    function_name,
839                    function_line,
840                );
841            }
842            Stmt::Return(ret) => {
843                if let Some(ref value) = ret.value {
844                    self.visit_expr_for_names(
845                        value,
846                        file_path,
847                        content,
848                        declared_params,
849                        local_vars,
850                        function_name,
851                        function_line,
852                    );
853                }
854            }
855            Stmt::If(if_stmt) => {
856                self.visit_expr_for_names(
857                    &if_stmt.test,
858                    file_path,
859                    content,
860                    declared_params,
861                    local_vars,
862                    function_name,
863                    function_line,
864                );
865                for stmt in &if_stmt.body {
866                    self.visit_stmt_for_names(
867                        stmt,
868                        file_path,
869                        content,
870                        declared_params,
871                        local_vars,
872                        function_name,
873                        function_line,
874                    );
875                }
876                for stmt in &if_stmt.orelse {
877                    self.visit_stmt_for_names(
878                        stmt,
879                        file_path,
880                        content,
881                        declared_params,
882                        local_vars,
883                        function_name,
884                        function_line,
885                    );
886                }
887            }
888            Stmt::While(while_stmt) => {
889                self.visit_expr_for_names(
890                    &while_stmt.test,
891                    file_path,
892                    content,
893                    declared_params,
894                    local_vars,
895                    function_name,
896                    function_line,
897                );
898                for stmt in &while_stmt.body {
899                    self.visit_stmt_for_names(
900                        stmt,
901                        file_path,
902                        content,
903                        declared_params,
904                        local_vars,
905                        function_name,
906                        function_line,
907                    );
908                }
909            }
910            Stmt::For(for_stmt) => {
911                self.visit_expr_for_names(
912                    &for_stmt.iter,
913                    file_path,
914                    content,
915                    declared_params,
916                    local_vars,
917                    function_name,
918                    function_line,
919                );
920                for stmt in &for_stmt.body {
921                    self.visit_stmt_for_names(
922                        stmt,
923                        file_path,
924                        content,
925                        declared_params,
926                        local_vars,
927                        function_name,
928                        function_line,
929                    );
930                }
931            }
932            Stmt::With(with_stmt) => {
933                for item in &with_stmt.items {
934                    self.visit_expr_for_names(
935                        &item.context_expr,
936                        file_path,
937                        content,
938                        declared_params,
939                        local_vars,
940                        function_name,
941                        function_line,
942                    );
943                }
944                for stmt in &with_stmt.body {
945                    self.visit_stmt_for_names(
946                        stmt,
947                        file_path,
948                        content,
949                        declared_params,
950                        local_vars,
951                        function_name,
952                        function_line,
953                    );
954                }
955            }
956            Stmt::AsyncFor(for_stmt) => {
957                self.visit_expr_for_names(
958                    &for_stmt.iter,
959                    file_path,
960                    content,
961                    declared_params,
962                    local_vars,
963                    function_name,
964                    function_line,
965                );
966                for stmt in &for_stmt.body {
967                    self.visit_stmt_for_names(
968                        stmt,
969                        file_path,
970                        content,
971                        declared_params,
972                        local_vars,
973                        function_name,
974                        function_line,
975                    );
976                }
977            }
978            Stmt::AsyncWith(with_stmt) => {
979                for item in &with_stmt.items {
980                    self.visit_expr_for_names(
981                        &item.context_expr,
982                        file_path,
983                        content,
984                        declared_params,
985                        local_vars,
986                        function_name,
987                        function_line,
988                    );
989                }
990                for stmt in &with_stmt.body {
991                    self.visit_stmt_for_names(
992                        stmt,
993                        file_path,
994                        content,
995                        declared_params,
996                        local_vars,
997                        function_name,
998                        function_line,
999                    );
1000                }
1001            }
1002            Stmt::Assert(assert_stmt) => {
1003                self.visit_expr_for_names(
1004                    &assert_stmt.test,
1005                    file_path,
1006                    content,
1007                    declared_params,
1008                    local_vars,
1009                    function_name,
1010                    function_line,
1011                );
1012                if let Some(ref msg) = assert_stmt.msg {
1013                    self.visit_expr_for_names(
1014                        msg,
1015                        file_path,
1016                        content,
1017                        declared_params,
1018                        local_vars,
1019                        function_name,
1020                        function_line,
1021                    );
1022                }
1023            }
1024            _ => {} // Other statement types
1025        }
1026    }
1027
1028    #[allow(clippy::too_many_arguments)]
1029    fn visit_expr_for_names(
1030        &self,
1031        expr: &Expr,
1032        file_path: &PathBuf,
1033        content: &str,
1034        declared_params: &std::collections::HashSet<String>,
1035        local_vars: &std::collections::HashMap<String, usize>,
1036        function_name: &str,
1037        function_line: usize,
1038    ) {
1039        match expr {
1040            Expr::Name(name) => {
1041                let name_str = name.id.as_str();
1042                let line = self.get_line_from_offset(name.range.start().to_usize(), content);
1043
1044                // Check if this name is a known fixture and not a declared parameter
1045                // For local variables, only exclude them if they're defined BEFORE the current line
1046                // (Python variables are only in scope after they're assigned)
1047                let is_local_var_in_scope = local_vars
1048                    .get(name_str)
1049                    .map(|def_line| *def_line < line)
1050                    .unwrap_or(false);
1051
1052                if !declared_params.contains(name_str)
1053                    && !is_local_var_in_scope
1054                    && self.is_available_fixture(file_path, name_str)
1055                {
1056                    let start_char =
1057                        self.get_char_position_from_offset(name.range.start().to_usize(), content);
1058                    let end_char =
1059                        self.get_char_position_from_offset(name.range.end().to_usize(), content);
1060
1061                    info!(
1062                        "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1063                        name_str, file_path, line, start_char, function_name
1064                    );
1065
1066                    let undeclared = UndeclaredFixture {
1067                        name: name_str.to_string(),
1068                        file_path: file_path.clone(),
1069                        line,
1070                        start_char,
1071                        end_char,
1072                        function_name: function_name.to_string(),
1073                        function_line,
1074                    };
1075
1076                    self.undeclared_fixtures
1077                        .entry(file_path.clone())
1078                        .or_default()
1079                        .push(undeclared);
1080                }
1081            }
1082            Expr::Call(call) => {
1083                self.visit_expr_for_names(
1084                    &call.func,
1085                    file_path,
1086                    content,
1087                    declared_params,
1088                    local_vars,
1089                    function_name,
1090                    function_line,
1091                );
1092                for arg in &call.args {
1093                    self.visit_expr_for_names(
1094                        arg,
1095                        file_path,
1096                        content,
1097                        declared_params,
1098                        local_vars,
1099                        function_name,
1100                        function_line,
1101                    );
1102                }
1103            }
1104            Expr::Attribute(attr) => {
1105                self.visit_expr_for_names(
1106                    &attr.value,
1107                    file_path,
1108                    content,
1109                    declared_params,
1110                    local_vars,
1111                    function_name,
1112                    function_line,
1113                );
1114            }
1115            Expr::BinOp(binop) => {
1116                self.visit_expr_for_names(
1117                    &binop.left,
1118                    file_path,
1119                    content,
1120                    declared_params,
1121                    local_vars,
1122                    function_name,
1123                    function_line,
1124                );
1125                self.visit_expr_for_names(
1126                    &binop.right,
1127                    file_path,
1128                    content,
1129                    declared_params,
1130                    local_vars,
1131                    function_name,
1132                    function_line,
1133                );
1134            }
1135            Expr::UnaryOp(unaryop) => {
1136                self.visit_expr_for_names(
1137                    &unaryop.operand,
1138                    file_path,
1139                    content,
1140                    declared_params,
1141                    local_vars,
1142                    function_name,
1143                    function_line,
1144                );
1145            }
1146            Expr::Compare(compare) => {
1147                self.visit_expr_for_names(
1148                    &compare.left,
1149                    file_path,
1150                    content,
1151                    declared_params,
1152                    local_vars,
1153                    function_name,
1154                    function_line,
1155                );
1156                for comparator in &compare.comparators {
1157                    self.visit_expr_for_names(
1158                        comparator,
1159                        file_path,
1160                        content,
1161                        declared_params,
1162                        local_vars,
1163                        function_name,
1164                        function_line,
1165                    );
1166                }
1167            }
1168            Expr::Subscript(subscript) => {
1169                self.visit_expr_for_names(
1170                    &subscript.value,
1171                    file_path,
1172                    content,
1173                    declared_params,
1174                    local_vars,
1175                    function_name,
1176                    function_line,
1177                );
1178                self.visit_expr_for_names(
1179                    &subscript.slice,
1180                    file_path,
1181                    content,
1182                    declared_params,
1183                    local_vars,
1184                    function_name,
1185                    function_line,
1186                );
1187            }
1188            Expr::List(list) => {
1189                for elt in &list.elts {
1190                    self.visit_expr_for_names(
1191                        elt,
1192                        file_path,
1193                        content,
1194                        declared_params,
1195                        local_vars,
1196                        function_name,
1197                        function_line,
1198                    );
1199                }
1200            }
1201            Expr::Tuple(tuple) => {
1202                for elt in &tuple.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::Dict(dict) => {
1215                for k in dict.keys.iter().flatten() {
1216                    self.visit_expr_for_names(
1217                        k,
1218                        file_path,
1219                        content,
1220                        declared_params,
1221                        local_vars,
1222                        function_name,
1223                        function_line,
1224                    );
1225                }
1226                for value in &dict.values {
1227                    self.visit_expr_for_names(
1228                        value,
1229                        file_path,
1230                        content,
1231                        declared_params,
1232                        local_vars,
1233                        function_name,
1234                        function_line,
1235                    );
1236                }
1237            }
1238            Expr::Await(await_expr) => {
1239                // Handle await expressions (async functions)
1240                self.visit_expr_for_names(
1241                    &await_expr.value,
1242                    file_path,
1243                    content,
1244                    declared_params,
1245                    local_vars,
1246                    function_name,
1247                    function_line,
1248                );
1249            }
1250            _ => {} // Other expression types
1251        }
1252    }
1253
1254    fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1255        // Check if this fixture exists and is available at this file location
1256        if let Some(definitions) = self.definitions.get(fixture_name) {
1257            // Check if any definition is available from this file location
1258            for def in definitions.iter() {
1259                // Fixture is available if it's in the same file or in a conftest.py in a parent directory
1260                if def.file_path == file_path {
1261                    return true;
1262                }
1263
1264                // Check if it's in a conftest.py in a parent directory
1265                if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1266                    && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1267                {
1268                    return true;
1269                }
1270
1271                // Check if it's in a virtual environment (third-party fixture)
1272                if def.file_path.to_string_lossy().contains("site-packages") {
1273                    return true;
1274                }
1275            }
1276        }
1277        false
1278    }
1279
1280    fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1281        // Python docstrings are the first statement in a function if it's an Expr containing a Constant string
1282        if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1283            if let Expr::Constant(constant) = &*expr_stmt.value {
1284                // Check if the constant is a string
1285                if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1286                    return Some(self.format_docstring(s.to_string()));
1287                }
1288            }
1289        }
1290        None
1291    }
1292
1293    fn format_docstring(&self, docstring: String) -> String {
1294        // Process docstring similar to Python's inspect.cleandoc()
1295        // 1. Split into lines
1296        let lines: Vec<&str> = docstring.lines().collect();
1297
1298        if lines.is_empty() {
1299            return String::new();
1300        }
1301
1302        // 2. Strip leading and trailing empty lines
1303        let mut start = 0;
1304        let mut end = lines.len();
1305
1306        while start < lines.len() && lines[start].trim().is_empty() {
1307            start += 1;
1308        }
1309
1310        while end > start && lines[end - 1].trim().is_empty() {
1311            end -= 1;
1312        }
1313
1314        if start >= end {
1315            return String::new();
1316        }
1317
1318        let lines = &lines[start..end];
1319
1320        // 3. Find minimum indentation (ignoring first line if it's not empty)
1321        let mut min_indent = usize::MAX;
1322        for (i, line) in lines.iter().enumerate() {
1323            if i == 0 && !line.trim().is_empty() {
1324                // First line might not be indented, skip it
1325                continue;
1326            }
1327
1328            if !line.trim().is_empty() {
1329                let indent = line.len() - line.trim_start().len();
1330                min_indent = min_indent.min(indent);
1331            }
1332        }
1333
1334        if min_indent == usize::MAX {
1335            min_indent = 0;
1336        }
1337
1338        // 4. Remove the common indentation from all lines (except possibly first)
1339        let mut result = Vec::new();
1340        for (i, line) in lines.iter().enumerate() {
1341            if i == 0 {
1342                // First line: just trim it
1343                result.push(line.trim().to_string());
1344            } else if line.trim().is_empty() {
1345                // Empty line: keep it empty
1346                result.push(String::new());
1347            } else {
1348                // Remove common indentation
1349                let dedented = if line.len() > min_indent {
1350                    &line[min_indent..]
1351                } else {
1352                    line.trim_start()
1353                };
1354                result.push(dedented.to_string());
1355            }
1356        }
1357
1358        // 5. Join lines back together
1359        result.join("\n")
1360    }
1361
1362    fn get_line_from_offset(&self, offset: usize, content: &str) -> usize {
1363        // Count newlines before this offset, then add 1 for 1-based line numbers
1364        content[..offset].matches('\n').count() + 1
1365    }
1366
1367    fn get_char_position_from_offset(&self, offset: usize, content: &str) -> usize {
1368        // Find the last newline before this offset
1369        if let Some(line_start) = content[..offset].rfind('\n') {
1370            // Character position is offset from start of line (after the newline)
1371            offset - line_start - 1
1372        } else {
1373            // No newline found, we're on the first line
1374            offset
1375        }
1376    }
1377
1378    /// Find fixture definition for a given position in a file
1379    pub fn find_fixture_definition(
1380        &self,
1381        file_path: &Path,
1382        line: u32,
1383        character: u32,
1384    ) -> Option<FixtureDefinition> {
1385        debug!(
1386            "find_fixture_definition: file={:?}, line={}, char={}",
1387            file_path, line, character
1388        );
1389
1390        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
1391
1392        // Read the file content - try cache first, then file system
1393        let content = if let Some(cached) = self.file_cache.get(file_path) {
1394            cached.clone()
1395        } else {
1396            std::fs::read_to_string(file_path).ok()?
1397        };
1398        let lines: Vec<&str> = content.lines().collect();
1399
1400        if target_line == 0 || target_line > lines.len() {
1401            return None;
1402        }
1403
1404        let line_content = lines[target_line - 1];
1405        debug!("Line content: {}", line_content);
1406
1407        // Extract the word at the character position
1408        let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1409        debug!("Word at cursor: {:?}", word_at_cursor);
1410
1411        // Check if we're inside a fixture definition with the same name (self-referencing)
1412        // In that case, we should skip the current definition and find the parent
1413        let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1414
1415        // First, check if this word matches any fixture usage on this line
1416        // AND that the cursor is within the character range of that usage
1417        if let Some(usages) = self.usages.get(file_path) {
1418            for usage in usages.iter() {
1419                if usage.line == target_line && usage.name == word_at_cursor {
1420                    // Check if cursor is within the character range of this usage
1421                    let cursor_pos = character as usize;
1422                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1423                        debug!(
1424                            "Cursor at {} is within usage range {}-{}: {}",
1425                            cursor_pos, usage.start_char, usage.end_char, usage.name
1426                        );
1427                        info!("Found fixture usage at cursor position: {}", usage.name);
1428
1429                        // If we're in a fixture definition with the same name, skip it when searching
1430                        if let Some(ref current_def) = current_fixture_def {
1431                            if current_def.name == word_at_cursor {
1432                                info!(
1433                                    "Self-referencing fixture detected, finding parent definition"
1434                                );
1435                                return self.find_closest_definition_excluding(
1436                                    file_path,
1437                                    &usage.name,
1438                                    Some(current_def),
1439                                );
1440                            }
1441                        }
1442
1443                        // Find the closest definition for this fixture
1444                        return self.find_closest_definition(file_path, &usage.name);
1445                    }
1446                }
1447            }
1448        }
1449
1450        debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1451        None
1452    }
1453
1454    /// Get the fixture definition at a specific line (if the line is a fixture definition)
1455    fn get_fixture_definition_at_line(
1456        &self,
1457        file_path: &Path,
1458        line: usize,
1459    ) -> Option<FixtureDefinition> {
1460        for entry in self.definitions.iter() {
1461            for def in entry.value().iter() {
1462                if def.file_path == file_path && def.line == line {
1463                    return Some(def.clone());
1464                }
1465            }
1466        }
1467        None
1468    }
1469
1470    /// Public method to get the fixture definition at a specific line and name
1471    /// Used when cursor is on a fixture definition line (not a usage)
1472    pub fn get_definition_at_line(
1473        &self,
1474        file_path: &Path,
1475        line: usize,
1476        fixture_name: &str,
1477    ) -> Option<FixtureDefinition> {
1478        if let Some(definitions) = self.definitions.get(fixture_name) {
1479            for def in definitions.iter() {
1480                if def.file_path == file_path && def.line == line {
1481                    return Some(def.clone());
1482                }
1483            }
1484        }
1485        None
1486    }
1487
1488    fn find_closest_definition(
1489        &self,
1490        file_path: &Path,
1491        fixture_name: &str,
1492    ) -> Option<FixtureDefinition> {
1493        let definitions = self.definitions.get(fixture_name)?;
1494
1495        // Priority 1: Check if fixture is defined in the same file (highest priority)
1496        // If multiple definitions exist in the same file, return the last one (pytest semantics)
1497        debug!(
1498            "Checking for fixture {} in same file: {:?}",
1499            fixture_name, file_path
1500        );
1501        let same_file_defs: Vec<_> = definitions
1502            .iter()
1503            .filter(|def| def.file_path == file_path)
1504            .collect();
1505
1506        if !same_file_defs.is_empty() {
1507            // Return the last definition (highest line number) - pytest uses last definition
1508            let last_def = same_file_defs.iter().max_by_key(|def| def.line).unwrap();
1509            info!(
1510                "Found fixture {} in same file at line {} (using last definition)",
1511                fixture_name, last_def.line
1512            );
1513            return Some((*last_def).clone());
1514        }
1515
1516        // Priority 2: Search upward through conftest.py files in parent directories
1517        // Start from the current file's directory and search upward
1518        let mut current_dir = file_path.parent()?;
1519
1520        debug!(
1521            "Searching for fixture {} in conftest.py files starting from {:?}",
1522            fixture_name, current_dir
1523        );
1524        loop {
1525            // Check for conftest.py in current directory
1526            let conftest_path = current_dir.join("conftest.py");
1527            debug!("  Checking conftest.py at: {:?}", conftest_path);
1528
1529            for def in definitions.iter() {
1530                if def.file_path == conftest_path {
1531                    info!(
1532                        "Found fixture {} in conftest.py: {:?}",
1533                        fixture_name, conftest_path
1534                    );
1535                    return Some(def.clone());
1536                }
1537            }
1538
1539            // Move up one directory
1540            match current_dir.parent() {
1541                Some(parent) => current_dir = parent,
1542                None => break,
1543            }
1544        }
1545
1546        // If no conftest.py found, return the first definition
1547        warn!(
1548            "No fixture {} found following priority rules, returning first available",
1549            fixture_name
1550        );
1551        definitions.iter().next().cloned()
1552    }
1553
1554    /// Find the closest definition for a fixture, excluding a specific definition
1555    /// This is useful for self-referencing fixtures where we need to find the parent definition
1556    fn find_closest_definition_excluding(
1557        &self,
1558        file_path: &Path,
1559        fixture_name: &str,
1560        exclude: Option<&FixtureDefinition>,
1561    ) -> Option<FixtureDefinition> {
1562        let definitions = self.definitions.get(fixture_name)?;
1563
1564        // Priority 1: Check if fixture is defined in the same file (highest priority)
1565        // but skip the excluded definition
1566        // If multiple definitions exist, use the last one (pytest semantics)
1567        debug!(
1568            "Checking for fixture {} in same file: {:?} (excluding: {:?})",
1569            fixture_name, file_path, exclude
1570        );
1571        let same_file_defs: Vec<_> = definitions
1572            .iter()
1573            .filter(|def| {
1574                if def.file_path != file_path {
1575                    return false;
1576                }
1577                // Skip the excluded definition
1578                if let Some(excluded) = exclude {
1579                    if def == &excluded {
1580                        debug!("Skipping excluded definition at line {}", def.line);
1581                        return false;
1582                    }
1583                }
1584                true
1585            })
1586            .collect();
1587
1588        if !same_file_defs.is_empty() {
1589            // Return the last definition (highest line number) - pytest uses last definition
1590            let last_def = same_file_defs.iter().max_by_key(|def| def.line).unwrap();
1591            info!(
1592                "Found fixture {} in same file at line {} (using last definition, excluding specified)",
1593                fixture_name, last_def.line
1594            );
1595            return Some((*last_def).clone());
1596        }
1597
1598        // Priority 2: Search upward through conftest.py files in parent directories
1599        let mut current_dir = file_path.parent()?;
1600
1601        debug!(
1602            "Searching for fixture {} in conftest.py files starting from {:?}",
1603            fixture_name, current_dir
1604        );
1605        loop {
1606            let conftest_path = current_dir.join("conftest.py");
1607            debug!("  Checking conftest.py at: {:?}", conftest_path);
1608
1609            for def in definitions.iter() {
1610                if def.file_path == conftest_path {
1611                    // Skip the excluded definition (though it's unlikely to be in a different file)
1612                    if let Some(excluded) = exclude {
1613                        if def == excluded {
1614                            debug!("Skipping excluded definition at line {}", def.line);
1615                            continue;
1616                        }
1617                    }
1618                    info!(
1619                        "Found fixture {} in conftest.py: {:?}",
1620                        fixture_name, conftest_path
1621                    );
1622                    return Some(def.clone());
1623                }
1624            }
1625
1626            // Move up one directory
1627            match current_dir.parent() {
1628                Some(parent) => current_dir = parent,
1629                None => break,
1630            }
1631        }
1632
1633        // If no conftest.py found, return the first definition that's not excluded
1634        warn!(
1635            "No fixture {} found following priority rules, returning first available (excluding specified)",
1636            fixture_name
1637        );
1638        definitions
1639            .iter()
1640            .find(|def| {
1641                if let Some(excluded) = exclude {
1642                    def != &excluded
1643                } else {
1644                    true
1645                }
1646            })
1647            .cloned()
1648    }
1649
1650    /// Find the fixture name at a given position (either definition or usage)
1651    pub fn find_fixture_at_position(
1652        &self,
1653        file_path: &Path,
1654        line: u32,
1655        character: u32,
1656    ) -> Option<String> {
1657        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
1658
1659        debug!(
1660            "find_fixture_at_position: file={:?}, line={}, char={}",
1661            file_path, target_line, character
1662        );
1663
1664        // Read the file content - try cache first, then file system
1665        let content = if let Some(cached) = self.file_cache.get(file_path) {
1666            cached.clone()
1667        } else {
1668            std::fs::read_to_string(file_path).ok()?
1669        };
1670        let lines: Vec<&str> = content.lines().collect();
1671
1672        if target_line == 0 || target_line > lines.len() {
1673            return None;
1674        }
1675
1676        let line_content = lines[target_line - 1];
1677        debug!("Line content: {}", line_content);
1678
1679        // Extract the word at the character position
1680        let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
1681        debug!("Word at cursor: {:?}", word_at_cursor);
1682
1683        // Check if this word matches any fixture usage on this line
1684        // AND that the cursor is within the character range of that usage
1685        if let Some(usages) = self.usages.get(file_path) {
1686            for usage in usages.iter() {
1687                if usage.line == target_line {
1688                    // Check if cursor is within the character range of this usage
1689                    let cursor_pos = character as usize;
1690                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1691                        debug!(
1692                            "Cursor at {} is within usage range {}-{}: {}",
1693                            cursor_pos, usage.start_char, usage.end_char, usage.name
1694                        );
1695                        info!("Found fixture usage at cursor position: {}", usage.name);
1696                        return Some(usage.name.clone());
1697                    }
1698                }
1699            }
1700        }
1701
1702        // If no usage matched, check if we're on a fixture definition line
1703        // (but only if the cursor is NOT on a parameter name)
1704        for entry in self.definitions.iter() {
1705            for def in entry.value().iter() {
1706                if def.file_path == file_path && def.line == target_line {
1707                    // Check if the cursor is on the function name itself, not a parameter
1708                    if let Some(ref word) = word_at_cursor {
1709                        if word == &def.name {
1710                            info!(
1711                                "Found fixture definition name at cursor position: {}",
1712                                def.name
1713                            );
1714                            return Some(def.name.clone());
1715                        }
1716                    }
1717                    // If cursor is elsewhere on the definition line, don't return the fixture name
1718                    // unless it matches a parameter (which would be a usage)
1719                }
1720            }
1721        }
1722
1723        debug!("No fixture found at cursor position");
1724        None
1725    }
1726
1727    fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
1728        let chars: Vec<char> = line.chars().collect();
1729
1730        // If cursor is beyond the line, return None
1731        if character > chars.len() {
1732            return None;
1733        }
1734
1735        // Check if cursor is ON an identifier character
1736        if character < chars.len() {
1737            let c = chars[character];
1738            if c.is_alphanumeric() || c == '_' {
1739                // Cursor is ON an identifier character, extract the word
1740                let mut start = character;
1741                while start > 0 {
1742                    let prev_c = chars[start - 1];
1743                    if !prev_c.is_alphanumeric() && prev_c != '_' {
1744                        break;
1745                    }
1746                    start -= 1;
1747                }
1748
1749                let mut end = character;
1750                while end < chars.len() {
1751                    let curr_c = chars[end];
1752                    if !curr_c.is_alphanumeric() && curr_c != '_' {
1753                        break;
1754                    }
1755                    end += 1;
1756                }
1757
1758                if start < end {
1759                    return Some(chars[start..end].iter().collect());
1760                }
1761            }
1762        }
1763
1764        None
1765    }
1766
1767    /// Find all references (usages) of a fixture by name
1768    pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
1769        info!("Finding all references for fixture: {}", fixture_name);
1770
1771        let mut all_references = Vec::new();
1772
1773        // Iterate through all files that have usages
1774        for entry in self.usages.iter() {
1775            let file_path = entry.key();
1776            let usages = entry.value();
1777
1778            // Find all usages of this fixture in this file
1779            for usage in usages.iter() {
1780                if usage.name == fixture_name {
1781                    debug!(
1782                        "Found reference to {} in {:?} at line {}",
1783                        fixture_name, file_path, usage.line
1784                    );
1785                    all_references.push(usage.clone());
1786                }
1787            }
1788        }
1789
1790        info!(
1791            "Found {} total references for fixture: {}",
1792            all_references.len(),
1793            fixture_name
1794        );
1795        all_references
1796    }
1797
1798    /// Find all references (usages) that would resolve to a specific fixture definition
1799    /// This respects the priority rules: same file > closest conftest.py > parent conftest.py
1800    ///
1801    /// For fixture overriding, this handles self-referencing parameters correctly:
1802    /// If a fixture parameter appears on the same line as a fixture definition with the same name,
1803    /// we exclude that definition when resolving, so it finds the parent instead.
1804    pub fn find_references_for_definition(
1805        &self,
1806        definition: &FixtureDefinition,
1807    ) -> Vec<FixtureUsage> {
1808        info!(
1809            "Finding references for specific definition: {} at {:?}:{}",
1810            definition.name, definition.file_path, definition.line
1811        );
1812
1813        let mut matching_references = Vec::new();
1814
1815        // Get all usages of this fixture name
1816        for entry in self.usages.iter() {
1817            let file_path = entry.key();
1818            let usages = entry.value();
1819
1820            for usage in usages.iter() {
1821                if usage.name == definition.name {
1822                    // Check if this usage is on the same line as a fixture definition with the same name
1823                    // (i.e., a self-referencing fixture parameter like "def foo(foo):")
1824                    let fixture_def_at_line =
1825                        self.get_fixture_definition_at_line(file_path, usage.line);
1826
1827                    let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
1828                        if current_def.name == usage.name {
1829                            // Self-referencing parameter - exclude current definition and find parent
1830                            debug!(
1831                                "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
1832                                file_path, usage.line, current_def.line
1833                            );
1834                            self.find_closest_definition_excluding(
1835                                file_path,
1836                                &usage.name,
1837                                Some(current_def),
1838                            )
1839                        } else {
1840                            // Different fixture - use normal resolution
1841                            self.find_closest_definition(file_path, &usage.name)
1842                        }
1843                    } else {
1844                        // Not on a fixture definition line - use normal resolution
1845                        self.find_closest_definition(file_path, &usage.name)
1846                    };
1847
1848                    if let Some(resolved_def) = resolved_def {
1849                        if resolved_def == *definition {
1850                            debug!(
1851                                "Usage at {:?}:{} resolves to our definition",
1852                                file_path, usage.line
1853                            );
1854                            matching_references.push(usage.clone());
1855                        } else {
1856                            debug!(
1857                                "Usage at {:?}:{} resolves to different definition at {:?}:{}",
1858                                file_path, usage.line, resolved_def.file_path, resolved_def.line
1859                            );
1860                        }
1861                    }
1862                }
1863            }
1864        }
1865
1866        info!(
1867            "Found {} references that resolve to this specific definition",
1868            matching_references.len()
1869        );
1870        matching_references
1871    }
1872
1873    /// Get all undeclared fixture usages for a file
1874    pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
1875        self.undeclared_fixtures
1876            .get(file_path)
1877            .map(|entry| entry.value().clone())
1878            .unwrap_or_default()
1879    }
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884    use super::*;
1885    use std::path::PathBuf;
1886
1887    #[test]
1888    fn test_fixture_definition_detection() {
1889        let db = FixtureDatabase::new();
1890
1891        let conftest_content = r#"
1892import pytest
1893
1894@pytest.fixture
1895def my_fixture():
1896    return 42
1897
1898@fixture
1899def another_fixture():
1900    return "hello"
1901"#;
1902
1903        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
1904        db.analyze_file(conftest_path.clone(), conftest_content);
1905
1906        // Check that fixtures were detected
1907        assert!(db.definitions.contains_key("my_fixture"));
1908        assert!(db.definitions.contains_key("another_fixture"));
1909
1910        // Check fixture details
1911        let my_fixture_defs = db.definitions.get("my_fixture").unwrap();
1912        assert_eq!(my_fixture_defs.len(), 1);
1913        assert_eq!(my_fixture_defs[0].name, "my_fixture");
1914        assert_eq!(my_fixture_defs[0].file_path, conftest_path);
1915    }
1916
1917    #[test]
1918    fn test_fixture_usage_detection() {
1919        let db = FixtureDatabase::new();
1920
1921        let test_content = r#"
1922def test_something(my_fixture, another_fixture):
1923    assert my_fixture == 42
1924    assert another_fixture == "hello"
1925
1926def test_other(my_fixture):
1927    assert my_fixture > 0
1928"#;
1929
1930        let test_path = PathBuf::from("/tmp/test/test_example.py");
1931        db.analyze_file(test_path.clone(), test_content);
1932
1933        // Check that usages were detected
1934        assert!(db.usages.contains_key(&test_path));
1935
1936        let usages = db.usages.get(&test_path).unwrap();
1937        // Should have usages from the first test function (we only track one function per file currently)
1938        assert!(usages.iter().any(|u| u.name == "my_fixture"));
1939        assert!(usages.iter().any(|u| u.name == "another_fixture"));
1940    }
1941
1942    #[test]
1943    fn test_go_to_definition() {
1944        let db = FixtureDatabase::new();
1945
1946        // Set up conftest.py with a fixture
1947        let conftest_content = r#"
1948import pytest
1949
1950@pytest.fixture
1951def my_fixture():
1952    return 42
1953"#;
1954
1955        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
1956        db.analyze_file(conftest_path.clone(), conftest_content);
1957
1958        // Set up a test file that uses the fixture
1959        let test_content = r#"
1960def test_something(my_fixture):
1961    assert my_fixture == 42
1962"#;
1963
1964        let test_path = PathBuf::from("/tmp/test/test_example.py");
1965        db.analyze_file(test_path.clone(), test_content);
1966
1967        // Try to find the definition from the test file
1968        // The usage is on line 2 (1-indexed) - that's where the function parameter is
1969        // In 0-indexed LSP coordinates, that's line 1
1970        // Character position 19 is where 'my_fixture' starts
1971        let definition = db.find_fixture_definition(&test_path, 1, 19);
1972
1973        assert!(definition.is_some(), "Definition should be found");
1974        let def = definition.unwrap();
1975        assert_eq!(def.name, "my_fixture");
1976        assert_eq!(def.file_path, conftest_path);
1977    }
1978
1979    #[test]
1980    fn test_fixture_decorator_variations() {
1981        let db = FixtureDatabase::new();
1982
1983        let conftest_content = r#"
1984import pytest
1985from pytest import fixture
1986
1987@pytest.fixture
1988def fixture1():
1989    pass
1990
1991@pytest.fixture()
1992def fixture2():
1993    pass
1994
1995@fixture
1996def fixture3():
1997    pass
1998
1999@fixture()
2000def fixture4():
2001    pass
2002"#;
2003
2004        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2005        db.analyze_file(conftest_path, conftest_content);
2006
2007        // Check all variations were detected
2008        assert!(db.definitions.contains_key("fixture1"));
2009        assert!(db.definitions.contains_key("fixture2"));
2010        assert!(db.definitions.contains_key("fixture3"));
2011        assert!(db.definitions.contains_key("fixture4"));
2012    }
2013
2014    #[test]
2015    fn test_fixture_in_test_file() {
2016        let db = FixtureDatabase::new();
2017
2018        // Test file with fixture defined in the same file
2019        let test_content = r#"
2020import pytest
2021
2022@pytest.fixture
2023def local_fixture():
2024    return 42
2025
2026def test_something(local_fixture):
2027    assert local_fixture == 42
2028"#;
2029
2030        let test_path = PathBuf::from("/tmp/test/test_example.py");
2031        db.analyze_file(test_path.clone(), test_content);
2032
2033        // Check that fixture was detected even though it's not in conftest.py
2034        assert!(db.definitions.contains_key("local_fixture"));
2035
2036        let local_fixture_defs = db.definitions.get("local_fixture").unwrap();
2037        assert_eq!(local_fixture_defs.len(), 1);
2038        assert_eq!(local_fixture_defs[0].name, "local_fixture");
2039        assert_eq!(local_fixture_defs[0].file_path, test_path);
2040
2041        // Check that usage was detected
2042        assert!(db.usages.contains_key(&test_path));
2043        let usages = db.usages.get(&test_path).unwrap();
2044        assert!(usages.iter().any(|u| u.name == "local_fixture"));
2045
2046        // Test go-to-definition for fixture in same file
2047        let usage_line = usages
2048            .iter()
2049            .find(|u| u.name == "local_fixture")
2050            .map(|u| u.line)
2051            .unwrap();
2052
2053        // Character position 19 is where 'local_fixture' starts in "def test_something(local_fixture):"
2054        let definition = db.find_fixture_definition(&test_path, (usage_line - 1) as u32, 19);
2055        assert!(
2056            definition.is_some(),
2057            "Should find definition for fixture in same file. Line: {}, char: 19",
2058            usage_line
2059        );
2060        let def = definition.unwrap();
2061        assert_eq!(def.name, "local_fixture");
2062        assert_eq!(def.file_path, test_path);
2063    }
2064
2065    #[test]
2066    fn test_async_test_functions() {
2067        let db = FixtureDatabase::new();
2068
2069        // Test file with async test function
2070        let test_content = r#"
2071import pytest
2072
2073@pytest.fixture
2074def my_fixture():
2075    return 42
2076
2077async def test_async_function(my_fixture):
2078    assert my_fixture == 42
2079
2080def test_sync_function(my_fixture):
2081    assert my_fixture == 42
2082"#;
2083
2084        let test_path = PathBuf::from("/tmp/test/test_async.py");
2085        db.analyze_file(test_path.clone(), test_content);
2086
2087        // Check that fixture was detected
2088        assert!(db.definitions.contains_key("my_fixture"));
2089
2090        // Check that both async and sync test functions have their usages detected
2091        assert!(db.usages.contains_key(&test_path));
2092        let usages = db.usages.get(&test_path).unwrap();
2093
2094        // Should have 2 usages (one from async, one from sync)
2095        let fixture_usages: Vec<_> = usages.iter().filter(|u| u.name == "my_fixture").collect();
2096        assert_eq!(
2097            fixture_usages.len(),
2098            2,
2099            "Should detect fixture usage in both async and sync tests"
2100        );
2101    }
2102
2103    #[test]
2104    fn test_extract_word_at_position() {
2105        let db = FixtureDatabase::new();
2106
2107        // Test basic word extraction
2108        let line = "def test_something(my_fixture):";
2109
2110        // Cursor on 'm' of 'my_fixture' (position 19)
2111        assert_eq!(
2112            db.extract_word_at_position(line, 19),
2113            Some("my_fixture".to_string())
2114        );
2115
2116        // Cursor on 'y' of 'my_fixture' (position 20)
2117        assert_eq!(
2118            db.extract_word_at_position(line, 20),
2119            Some("my_fixture".to_string())
2120        );
2121
2122        // Cursor on last 'e' of 'my_fixture' (position 28)
2123        assert_eq!(
2124            db.extract_word_at_position(line, 28),
2125            Some("my_fixture".to_string())
2126        );
2127
2128        // Cursor on 'd' of 'def' (position 0)
2129        assert_eq!(
2130            db.extract_word_at_position(line, 0),
2131            Some("def".to_string())
2132        );
2133
2134        // Cursor on space after 'def' (position 3) - should return None
2135        assert_eq!(db.extract_word_at_position(line, 3), None);
2136
2137        // Cursor on 't' of 'test_something' (position 4)
2138        assert_eq!(
2139            db.extract_word_at_position(line, 4),
2140            Some("test_something".to_string())
2141        );
2142
2143        // Cursor on opening parenthesis (position 18) - should return None
2144        assert_eq!(db.extract_word_at_position(line, 18), None);
2145
2146        // Cursor on closing parenthesis (position 29) - should return None
2147        assert_eq!(db.extract_word_at_position(line, 29), None);
2148
2149        // Cursor on colon (position 31) - should return None
2150        assert_eq!(db.extract_word_at_position(line, 31), None);
2151    }
2152
2153    #[test]
2154    fn test_extract_word_at_position_fixture_definition() {
2155        let db = FixtureDatabase::new();
2156
2157        let line = "@pytest.fixture";
2158
2159        // Cursor on '@' - should return None
2160        assert_eq!(db.extract_word_at_position(line, 0), None);
2161
2162        // Cursor on 'p' of 'pytest' (position 1)
2163        assert_eq!(
2164            db.extract_word_at_position(line, 1),
2165            Some("pytest".to_string())
2166        );
2167
2168        // Cursor on '.' - should return None
2169        assert_eq!(db.extract_word_at_position(line, 7), None);
2170
2171        // Cursor on 'f' of 'fixture' (position 8)
2172        assert_eq!(
2173            db.extract_word_at_position(line, 8),
2174            Some("fixture".to_string())
2175        );
2176
2177        let line2 = "def foo(other_fixture):";
2178
2179        // Cursor on 'd' of 'def'
2180        assert_eq!(
2181            db.extract_word_at_position(line2, 0),
2182            Some("def".to_string())
2183        );
2184
2185        // Cursor on space after 'def' - should return None
2186        assert_eq!(db.extract_word_at_position(line2, 3), None);
2187
2188        // Cursor on 'f' of 'foo'
2189        assert_eq!(
2190            db.extract_word_at_position(line2, 4),
2191            Some("foo".to_string())
2192        );
2193
2194        // Cursor on 'o' of 'other_fixture'
2195        assert_eq!(
2196            db.extract_word_at_position(line2, 8),
2197            Some("other_fixture".to_string())
2198        );
2199
2200        // Cursor on parenthesis - should return None
2201        assert_eq!(db.extract_word_at_position(line2, 7), None);
2202    }
2203
2204    #[test]
2205    fn test_word_detection_only_on_fixtures() {
2206        let db = FixtureDatabase::new();
2207
2208        // Set up a conftest with a fixture
2209        let conftest_content = r#"
2210import pytest
2211
2212@pytest.fixture
2213def my_fixture():
2214    return 42
2215"#;
2216        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2217        db.analyze_file(conftest_path.clone(), conftest_content);
2218
2219        // Set up a test file
2220        let test_content = r#"
2221def test_something(my_fixture, regular_param):
2222    assert my_fixture == 42
2223"#;
2224        let test_path = PathBuf::from("/tmp/test/test_example.py");
2225        db.analyze_file(test_path.clone(), test_content);
2226
2227        // Line 2 is "def test_something(my_fixture, regular_param):"
2228        // Character positions:
2229        // 0: 'd' of 'def'
2230        // 4: 't' of 'test_something'
2231        // 19: 'm' of 'my_fixture'
2232        // 31: 'r' of 'regular_param'
2233
2234        // Cursor on 'def' - should NOT find a fixture (LSP line 1, 0-based)
2235        assert_eq!(db.find_fixture_definition(&test_path, 1, 0), None);
2236
2237        // Cursor on 'test_something' - should NOT find a fixture
2238        assert_eq!(db.find_fixture_definition(&test_path, 1, 4), None);
2239
2240        // Cursor on 'my_fixture' - SHOULD find the fixture
2241        let result = db.find_fixture_definition(&test_path, 1, 19);
2242        assert!(result.is_some());
2243        let def = result.unwrap();
2244        assert_eq!(def.name, "my_fixture");
2245
2246        // Cursor on 'regular_param' - should NOT find a fixture (it's not a fixture)
2247        assert_eq!(db.find_fixture_definition(&test_path, 1, 31), None);
2248
2249        // Cursor on comma or parenthesis - should NOT find a fixture
2250        assert_eq!(db.find_fixture_definition(&test_path, 1, 18), None); // '('
2251        assert_eq!(db.find_fixture_definition(&test_path, 1, 29), None); // ','
2252    }
2253
2254    #[test]
2255    fn test_self_referencing_fixture() {
2256        let db = FixtureDatabase::new();
2257
2258        // Set up a parent conftest.py with the original fixture
2259        let parent_conftest_content = r#"
2260import pytest
2261
2262@pytest.fixture
2263def foo():
2264    return "parent"
2265"#;
2266        let parent_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2267        db.analyze_file(parent_conftest_path.clone(), parent_conftest_content);
2268
2269        // Set up a child directory conftest.py that overrides foo, referencing itself
2270        let child_conftest_content = r#"
2271import pytest
2272
2273@pytest.fixture
2274def foo(foo):
2275    return foo + " child"
2276"#;
2277        let child_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2278        db.analyze_file(child_conftest_path.clone(), child_conftest_content);
2279
2280        // Now test go-to-definition on the parameter `foo` in the child fixture
2281        // Line 5 is "def foo(foo):" (1-indexed)
2282        // Character position 8 is the 'f' in the parameter name "foo"
2283        // LSP uses 0-indexed lines, so line 4 in LSP coordinates
2284
2285        let result = db.find_fixture_definition(&child_conftest_path, 4, 8);
2286
2287        assert!(
2288            result.is_some(),
2289            "Should find parent definition for self-referencing fixture"
2290        );
2291        let def = result.unwrap();
2292        assert_eq!(def.name, "foo");
2293        assert_eq!(
2294            def.file_path, parent_conftest_path,
2295            "Should resolve to parent conftest.py, not the child"
2296        );
2297        assert_eq!(def.line, 5, "Should point to line 5 of parent conftest.py");
2298    }
2299
2300    #[test]
2301    fn test_fixture_overriding_same_file() {
2302        let db = FixtureDatabase::new();
2303
2304        // A test file with multiple fixtures with the same name (unusual but valid)
2305        let test_content = r#"
2306import pytest
2307
2308@pytest.fixture
2309def my_fixture():
2310    return "first"
2311
2312@pytest.fixture
2313def my_fixture():
2314    return "second"
2315
2316def test_something(my_fixture):
2317    assert my_fixture == "second"
2318"#;
2319        let test_path = PathBuf::from("/tmp/test/test_example.py");
2320        db.analyze_file(test_path.clone(), test_content);
2321
2322        // When there are multiple definitions in the same file, the later one should win
2323        // (Python's behavior - later definitions override earlier ones)
2324
2325        // Test go-to-definition on the parameter in test_something
2326        // Line 12 is "def test_something(my_fixture):" (1-indexed)
2327        // Character position 19 is the 'm' in "my_fixture"
2328        // LSP uses 0-indexed lines, so line 11 in LSP coordinates
2329
2330        let result = db.find_fixture_definition(&test_path, 11, 19);
2331
2332        assert!(result.is_some(), "Should find fixture definition");
2333        let def = result.unwrap();
2334        assert_eq!(def.name, "my_fixture");
2335        assert_eq!(def.file_path, test_path);
2336        // The current implementation returns the first match in the same file
2337        // For true Python semantics, we'd want the last one, but that's a more complex change
2338        // For now, we just verify it finds *a* definition in the same file
2339    }
2340
2341    #[test]
2342    fn test_fixture_overriding_conftest_hierarchy() {
2343        let db = FixtureDatabase::new();
2344
2345        // Root conftest.py
2346        let root_conftest_content = r#"
2347import pytest
2348
2349@pytest.fixture
2350def shared_fixture():
2351    return "root"
2352"#;
2353        let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2354        db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2355
2356        // Subdirectory conftest.py that overrides the fixture
2357        let sub_conftest_content = r#"
2358import pytest
2359
2360@pytest.fixture
2361def shared_fixture():
2362    return "subdir"
2363"#;
2364        let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2365        db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2366
2367        // Test file in subdirectory
2368        let test_content = r#"
2369def test_something(shared_fixture):
2370    assert shared_fixture == "subdir"
2371"#;
2372        let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
2373        db.analyze_file(test_path.clone(), test_content);
2374
2375        // Go-to-definition from the test should find the closest conftest.py (subdir)
2376        // Line 2 is "def test_something(shared_fixture):" (1-indexed)
2377        // Character position 19 is the 's' in "shared_fixture"
2378        // LSP uses 0-indexed lines, so line 1 in LSP coordinates
2379
2380        let result = db.find_fixture_definition(&test_path, 1, 19);
2381
2382        assert!(result.is_some(), "Should find fixture definition");
2383        let def = result.unwrap();
2384        assert_eq!(def.name, "shared_fixture");
2385        assert_eq!(
2386            def.file_path, sub_conftest_path,
2387            "Should resolve to closest conftest.py"
2388        );
2389
2390        // Now test from a file in the parent directory
2391        let parent_test_content = r#"
2392def test_parent(shared_fixture):
2393    assert shared_fixture == "root"
2394"#;
2395        let parent_test_path = PathBuf::from("/tmp/test/test_parent.py");
2396        db.analyze_file(parent_test_path.clone(), parent_test_content);
2397
2398        let result = db.find_fixture_definition(&parent_test_path, 1, 16);
2399
2400        assert!(result.is_some(), "Should find fixture definition");
2401        let def = result.unwrap();
2402        assert_eq!(def.name, "shared_fixture");
2403        assert_eq!(
2404            def.file_path, root_conftest_path,
2405            "Should resolve to root conftest.py"
2406        );
2407    }
2408
2409    #[test]
2410    fn test_scoped_references() {
2411        let db = FixtureDatabase::new();
2412
2413        // Set up a root conftest.py with a fixture
2414        let root_conftest_content = r#"
2415import pytest
2416
2417@pytest.fixture
2418def shared_fixture():
2419    return "root"
2420"#;
2421        let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2422        db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2423
2424        // Set up subdirectory conftest.py that overrides the fixture
2425        let sub_conftest_content = r#"
2426import pytest
2427
2428@pytest.fixture
2429def shared_fixture():
2430    return "subdir"
2431"#;
2432        let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2433        db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2434
2435        // Test file in the root directory (uses root fixture)
2436        let root_test_content = r#"
2437def test_root(shared_fixture):
2438    assert shared_fixture == "root"
2439"#;
2440        let root_test_path = PathBuf::from("/tmp/test/test_root.py");
2441        db.analyze_file(root_test_path.clone(), root_test_content);
2442
2443        // Test file in subdirectory (uses subdir fixture)
2444        let sub_test_content = r#"
2445def test_sub(shared_fixture):
2446    assert shared_fixture == "subdir"
2447"#;
2448        let sub_test_path = PathBuf::from("/tmp/test/subdir/test_sub.py");
2449        db.analyze_file(sub_test_path.clone(), sub_test_content);
2450
2451        // Another test in subdirectory
2452        let sub_test2_content = r#"
2453def test_sub2(shared_fixture):
2454    assert shared_fixture == "subdir"
2455"#;
2456        let sub_test2_path = PathBuf::from("/tmp/test/subdir/test_sub2.py");
2457        db.analyze_file(sub_test2_path.clone(), sub_test2_content);
2458
2459        // Get the root definition
2460        let root_definitions = db.definitions.get("shared_fixture").unwrap();
2461        let root_definition = root_definitions
2462            .iter()
2463            .find(|d| d.file_path == root_conftest_path)
2464            .unwrap();
2465
2466        // Get the subdir definition
2467        let sub_definition = root_definitions
2468            .iter()
2469            .find(|d| d.file_path == sub_conftest_path)
2470            .unwrap();
2471
2472        // Find references for the root definition
2473        let root_refs = db.find_references_for_definition(root_definition);
2474
2475        // Should only include the test in the root directory
2476        assert_eq!(
2477            root_refs.len(),
2478            1,
2479            "Root definition should have 1 reference (from root test)"
2480        );
2481        assert_eq!(root_refs[0].file_path, root_test_path);
2482
2483        // Find references for the subdir definition
2484        let sub_refs = db.find_references_for_definition(sub_definition);
2485
2486        // Should include both tests in the subdirectory
2487        assert_eq!(
2488            sub_refs.len(),
2489            2,
2490            "Subdir definition should have 2 references (from subdir tests)"
2491        );
2492
2493        let sub_ref_paths: Vec<_> = sub_refs.iter().map(|r| &r.file_path).collect();
2494        assert!(sub_ref_paths.contains(&&sub_test_path));
2495        assert!(sub_ref_paths.contains(&&sub_test2_path));
2496
2497        // Verify that all references by name returns 3 total
2498        let all_refs = db.find_fixture_references("shared_fixture");
2499        assert_eq!(
2500            all_refs.len(),
2501            3,
2502            "Should find 3 total references across all scopes"
2503        );
2504    }
2505
2506    #[test]
2507    fn test_multiline_parameters() {
2508        let db = FixtureDatabase::new();
2509
2510        // Conftest with fixture
2511        let conftest_content = r#"
2512import pytest
2513
2514@pytest.fixture
2515def foo():
2516    return 42
2517"#;
2518        let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2519        db.analyze_file(conftest_path.clone(), conftest_content);
2520
2521        // Test file with multiline parameters
2522        let test_content = r#"
2523def test_xxx(
2524    foo,
2525):
2526    assert foo == 42
2527"#;
2528        let test_path = PathBuf::from("/tmp/test/test_example.py");
2529        db.analyze_file(test_path.clone(), test_content);
2530
2531        // Line 3 (1-indexed) is "    foo," - the parameter line
2532        // In LSP coordinates, that's line 2 (0-indexed)
2533        // Character position 4 is the 'f' in 'foo'
2534
2535        // Debug: Check what usages were recorded
2536        if let Some(usages) = db.usages.get(&test_path) {
2537            println!("Usages recorded:");
2538            for usage in usages.iter() {
2539                println!("  {} at line {} (1-indexed)", usage.name, usage.line);
2540            }
2541        } else {
2542            println!("No usages recorded for test file");
2543        }
2544
2545        // The content has a leading newline, so:
2546        // Line 1: (empty)
2547        // Line 2: def test_xxx(
2548        // Line 3:     foo,
2549        // Line 4: ):
2550        // Line 5:     assert foo == 42
2551
2552        // foo is at line 3 (1-indexed) = line 2 (0-indexed LSP)
2553        let result = db.find_fixture_definition(&test_path, 2, 4);
2554
2555        assert!(
2556            result.is_some(),
2557            "Should find fixture definition when cursor is on parameter line"
2558        );
2559        let def = result.unwrap();
2560        assert_eq!(def.name, "foo");
2561    }
2562
2563    #[test]
2564    fn test_find_references_from_usage() {
2565        let db = FixtureDatabase::new();
2566
2567        // Simple fixture and usage in the same file
2568        let test_content = r#"
2569import pytest
2570
2571@pytest.fixture
2572def foo(): ...
2573
2574
2575def test_xxx(foo):
2576    pass
2577"#;
2578        let test_path = PathBuf::from("/tmp/test/test_example.py");
2579        db.analyze_file(test_path.clone(), test_content);
2580
2581        // Get the foo definition
2582        let foo_defs = db.definitions.get("foo").unwrap();
2583        assert_eq!(foo_defs.len(), 1, "Should have exactly one foo definition");
2584        let foo_def = &foo_defs[0];
2585        assert_eq!(foo_def.line, 5, "foo definition should be on line 5");
2586
2587        // Get references for the definition
2588        let refs_from_def = db.find_references_for_definition(foo_def);
2589        println!("References from definition:");
2590        for r in &refs_from_def {
2591            println!("  {} at line {}", r.name, r.line);
2592        }
2593
2594        assert_eq!(
2595            refs_from_def.len(),
2596            1,
2597            "Should find 1 usage reference (test_xxx parameter)"
2598        );
2599        assert_eq!(refs_from_def[0].line, 8, "Usage should be on line 8");
2600
2601        // Now simulate what happens when user clicks on the usage (line 8, char 13 - the 'f' in 'foo')
2602        // This is LSP line 7 (0-indexed)
2603        let fixture_name = db.find_fixture_at_position(&test_path, 7, 13);
2604        println!(
2605            "\nfind_fixture_at_position(line 7, char 13): {:?}",
2606            fixture_name
2607        );
2608
2609        assert_eq!(
2610            fixture_name,
2611            Some("foo".to_string()),
2612            "Should find fixture name at usage position"
2613        );
2614
2615        let resolved_def = db.find_fixture_definition(&test_path, 7, 13);
2616        println!(
2617            "\nfind_fixture_definition(line 7, char 13): {:?}",
2618            resolved_def.as_ref().map(|d| (d.line, &d.file_path))
2619        );
2620
2621        assert!(resolved_def.is_some(), "Should resolve usage to definition");
2622        assert_eq!(
2623            resolved_def.unwrap(),
2624            *foo_def,
2625            "Should resolve to the correct definition"
2626        );
2627    }
2628
2629    #[test]
2630    fn test_find_references_with_ellipsis_body() {
2631        // This reproduces the structure from strawberry test_codegen.py
2632        let db = FixtureDatabase::new();
2633
2634        let test_content = r#"@pytest.fixture
2635def foo(): ...
2636
2637
2638def test_xxx(foo):
2639    pass
2640"#;
2641        let test_path = PathBuf::from("/tmp/test/test_codegen.py");
2642        db.analyze_file(test_path.clone(), test_content);
2643
2644        // Check what line foo definition is on
2645        let foo_defs = db.definitions.get("foo");
2646        println!(
2647            "foo definitions: {:?}",
2648            foo_defs
2649                .as_ref()
2650                .map(|defs| defs.iter().map(|d| d.line).collect::<Vec<_>>())
2651        );
2652
2653        // Check what line foo usage is on
2654        if let Some(usages) = db.usages.get(&test_path) {
2655            println!("usages:");
2656            for u in usages.iter() {
2657                println!("  {} at line {}", u.name, u.line);
2658            }
2659        }
2660
2661        assert!(foo_defs.is_some(), "Should find foo definition");
2662        let foo_def = &foo_defs.unwrap()[0];
2663
2664        // Get the usage line
2665        let usages = db.usages.get(&test_path).unwrap();
2666        let foo_usage = usages.iter().find(|u| u.name == "foo").unwrap();
2667
2668        // Test from usage position (LSP coordinates are 0-indexed)
2669        let usage_lsp_line = (foo_usage.line - 1) as u32;
2670        println!("\nTesting from usage at LSP line {}", usage_lsp_line);
2671
2672        let fixture_name = db.find_fixture_at_position(&test_path, usage_lsp_line, 13);
2673        assert_eq!(
2674            fixture_name,
2675            Some("foo".to_string()),
2676            "Should find foo at usage"
2677        );
2678
2679        let def_from_usage = db.find_fixture_definition(&test_path, usage_lsp_line, 13);
2680        assert!(
2681            def_from_usage.is_some(),
2682            "Should resolve usage to definition"
2683        );
2684        assert_eq!(def_from_usage.unwrap(), *foo_def);
2685    }
2686
2687    #[test]
2688    fn test_fixture_hierarchy_parent_references() {
2689        // Test that finding references from a parent fixture definition
2690        // includes child fixture definitions but NOT the child's usages
2691        let db = FixtureDatabase::new();
2692
2693        // Parent conftest
2694        let parent_content = r#"
2695import pytest
2696
2697@pytest.fixture
2698def cli_runner():
2699    """Parent fixture"""
2700    return "parent"
2701"#;
2702        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2703        db.analyze_file(parent_conftest.clone(), parent_content);
2704
2705        // Child conftest with override
2706        let child_content = r#"
2707import pytest
2708
2709@pytest.fixture
2710def cli_runner(cli_runner):
2711    """Child override that uses parent"""
2712    return cli_runner
2713"#;
2714        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2715        db.analyze_file(child_conftest.clone(), child_content);
2716
2717        // Test file in subdir using the child fixture
2718        let test_content = r#"
2719def test_one(cli_runner):
2720    pass
2721
2722def test_two(cli_runner):
2723    pass
2724"#;
2725        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2726        db.analyze_file(test_path.clone(), test_content);
2727
2728        // Get parent definition
2729        let parent_defs = db.definitions.get("cli_runner").unwrap();
2730        let parent_def = parent_defs
2731            .iter()
2732            .find(|d| d.file_path == parent_conftest)
2733            .unwrap();
2734
2735        println!(
2736            "\nParent definition: {:?}:{}",
2737            parent_def.file_path, parent_def.line
2738        );
2739
2740        // Find references for parent definition
2741        let refs = db.find_references_for_definition(parent_def);
2742
2743        println!("\nReferences for parent definition:");
2744        for r in &refs {
2745            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2746        }
2747
2748        // Parent references should include:
2749        // 1. The child fixture definition (line 5 in child conftest)
2750        // 2. The child's parameter that references the parent (line 5 in child conftest)
2751        // But NOT:
2752        // 3. test_one and test_two usages (they resolve to child, not parent)
2753
2754        assert!(
2755            refs.len() <= 2,
2756            "Parent should have at most 2 references: child definition and its parameter, got {}",
2757            refs.len()
2758        );
2759
2760        // Should include the child conftest
2761        let child_refs: Vec<_> = refs
2762            .iter()
2763            .filter(|r| r.file_path == child_conftest)
2764            .collect();
2765        assert!(
2766            !child_refs.is_empty(),
2767            "Parent references should include child fixture definition"
2768        );
2769
2770        // Should NOT include test file usages
2771        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2772        assert!(
2773            test_refs.is_empty(),
2774            "Parent references should NOT include child's test file usages"
2775        );
2776    }
2777
2778    #[test]
2779    fn test_fixture_hierarchy_child_references() {
2780        // Test that finding references from a child fixture definition
2781        // includes usages in the same directory (that resolve to the child)
2782        let db = FixtureDatabase::new();
2783
2784        // Parent conftest
2785        let parent_content = r#"
2786import pytest
2787
2788@pytest.fixture
2789def cli_runner():
2790    return "parent"
2791"#;
2792        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2793        db.analyze_file(parent_conftest.clone(), parent_content);
2794
2795        // Child conftest with override
2796        let child_content = r#"
2797import pytest
2798
2799@pytest.fixture
2800def cli_runner(cli_runner):
2801    return cli_runner
2802"#;
2803        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2804        db.analyze_file(child_conftest.clone(), child_content);
2805
2806        // Test file using child fixture
2807        let test_content = r#"
2808def test_one(cli_runner):
2809    pass
2810
2811def test_two(cli_runner):
2812    pass
2813"#;
2814        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2815        db.analyze_file(test_path.clone(), test_content);
2816
2817        // Get child definition
2818        let child_defs = db.definitions.get("cli_runner").unwrap();
2819        let child_def = child_defs
2820            .iter()
2821            .find(|d| d.file_path == child_conftest)
2822            .unwrap();
2823
2824        println!(
2825            "\nChild definition: {:?}:{}",
2826            child_def.file_path, child_def.line
2827        );
2828
2829        // Find references for child definition
2830        let refs = db.find_references_for_definition(child_def);
2831
2832        println!("\nReferences for child definition:");
2833        for r in &refs {
2834            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2835        }
2836
2837        // Child references should include test_one and test_two
2838        assert!(
2839            refs.len() >= 2,
2840            "Child should have at least 2 references from test file, got {}",
2841            refs.len()
2842        );
2843
2844        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2845        assert_eq!(
2846            test_refs.len(),
2847            2,
2848            "Should have 2 references from test file"
2849        );
2850    }
2851
2852    #[test]
2853    fn test_fixture_hierarchy_child_parameter_references() {
2854        // Test that finding references from a child fixture's parameter
2855        // (which references the parent) includes the child fixture definition
2856        let db = FixtureDatabase::new();
2857
2858        // Parent conftest
2859        let parent_content = r#"
2860import pytest
2861
2862@pytest.fixture
2863def cli_runner():
2864    return "parent"
2865"#;
2866        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2867        db.analyze_file(parent_conftest.clone(), parent_content);
2868
2869        // Child conftest with override
2870        let child_content = r#"
2871import pytest
2872
2873@pytest.fixture
2874def cli_runner(cli_runner):
2875    return cli_runner
2876"#;
2877        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2878        db.analyze_file(child_conftest.clone(), child_content);
2879
2880        // When user clicks on the parameter "cli_runner" in the child definition,
2881        // it should resolve to the parent definition
2882        // Line 5 (1-indexed) = line 4 (0-indexed LSP), char 15 is in the parameter name
2883        let resolved_def = db.find_fixture_definition(&child_conftest, 4, 15);
2884
2885        assert!(
2886            resolved_def.is_some(),
2887            "Child parameter should resolve to parent definition"
2888        );
2889
2890        let def = resolved_def.unwrap();
2891        assert_eq!(
2892            def.file_path, parent_conftest,
2893            "Should resolve to parent conftest"
2894        );
2895
2896        // Find references for parent definition
2897        let refs = db.find_references_for_definition(&def);
2898
2899        println!("\nReferences for parent (from child parameter):");
2900        for r in &refs {
2901            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2902        }
2903
2904        // Should include the child fixture's parameter usage
2905        let child_refs: Vec<_> = refs
2906            .iter()
2907            .filter(|r| r.file_path == child_conftest)
2908            .collect();
2909        assert!(
2910            !child_refs.is_empty(),
2911            "Parent references should include child fixture parameter"
2912        );
2913    }
2914
2915    #[test]
2916    fn test_fixture_hierarchy_usage_from_test() {
2917        // Test that finding references from a test function parameter
2918        // includes the definition it resolves to and other usages
2919        let db = FixtureDatabase::new();
2920
2921        // Parent conftest
2922        let parent_content = r#"
2923import pytest
2924
2925@pytest.fixture
2926def cli_runner():
2927    return "parent"
2928"#;
2929        let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2930        db.analyze_file(parent_conftest.clone(), parent_content);
2931
2932        // Child conftest with override
2933        let child_content = r#"
2934import pytest
2935
2936@pytest.fixture
2937def cli_runner(cli_runner):
2938    return cli_runner
2939"#;
2940        let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2941        db.analyze_file(child_conftest.clone(), child_content);
2942
2943        // Test file using child fixture
2944        let test_content = r#"
2945def test_one(cli_runner):
2946    pass
2947
2948def test_two(cli_runner):
2949    pass
2950
2951def test_three(cli_runner):
2952    pass
2953"#;
2954        let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2955        db.analyze_file(test_path.clone(), test_content);
2956
2957        // Click on cli_runner in test_one (line 2, 1-indexed = line 1, 0-indexed)
2958        let resolved_def = db.find_fixture_definition(&test_path, 1, 13);
2959
2960        assert!(
2961            resolved_def.is_some(),
2962            "Usage should resolve to child definition"
2963        );
2964
2965        let def = resolved_def.unwrap();
2966        assert_eq!(
2967            def.file_path, child_conftest,
2968            "Should resolve to child conftest (not parent)"
2969        );
2970
2971        // Find references for the resolved definition
2972        let refs = db.find_references_for_definition(&def);
2973
2974        println!("\nReferences for child (from test usage):");
2975        for r in &refs {
2976            println!("  {} at {:?}:{}", r.name, r.file_path, r.line);
2977        }
2978
2979        // Should include all three test usages
2980        let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2981        assert_eq!(test_refs.len(), 3, "Should find all 3 usages in test file");
2982    }
2983
2984    #[test]
2985    fn test_fixture_hierarchy_multiple_levels() {
2986        // Test a three-level hierarchy: grandparent -> parent -> child
2987        let db = FixtureDatabase::new();
2988
2989        // Grandparent
2990        let grandparent_content = r#"
2991import pytest
2992
2993@pytest.fixture
2994def db():
2995    return "grandparent_db"
2996"#;
2997        let grandparent_conftest = PathBuf::from("/tmp/project/conftest.py");
2998        db.analyze_file(grandparent_conftest.clone(), grandparent_content);
2999
3000        // Parent overrides
3001        let parent_content = r#"
3002import pytest
3003
3004@pytest.fixture
3005def db(db):
3006    return f"parent_{db}"
3007"#;
3008        let parent_conftest = PathBuf::from("/tmp/project/api/conftest.py");
3009        db.analyze_file(parent_conftest.clone(), parent_content);
3010
3011        // Child overrides again
3012        let child_content = r#"
3013import pytest
3014
3015@pytest.fixture
3016def db(db):
3017    return f"child_{db}"
3018"#;
3019        let child_conftest = PathBuf::from("/tmp/project/api/tests/conftest.py");
3020        db.analyze_file(child_conftest.clone(), child_content);
3021
3022        // Test file at child level
3023        let test_content = r#"
3024def test_db(db):
3025    pass
3026"#;
3027        let test_path = PathBuf::from("/tmp/project/api/tests/test_example.py");
3028        db.analyze_file(test_path.clone(), test_content);
3029
3030        // Get all definitions
3031        let all_defs = db.definitions.get("db").unwrap();
3032        assert_eq!(all_defs.len(), 3, "Should have 3 definitions");
3033
3034        let grandparent_def = all_defs
3035            .iter()
3036            .find(|d| d.file_path == grandparent_conftest)
3037            .unwrap();
3038        let parent_def = all_defs
3039            .iter()
3040            .find(|d| d.file_path == parent_conftest)
3041            .unwrap();
3042        let child_def = all_defs
3043            .iter()
3044            .find(|d| d.file_path == child_conftest)
3045            .unwrap();
3046
3047        // Test from test file - should resolve to child
3048        let resolved = db.find_fixture_definition(&test_path, 1, 12);
3049        assert_eq!(
3050            resolved.as_ref(),
3051            Some(child_def),
3052            "Test should use child definition"
3053        );
3054
3055        // Child's references should include test file
3056        let child_refs = db.find_references_for_definition(child_def);
3057        let test_refs: Vec<_> = child_refs
3058            .iter()
3059            .filter(|r| r.file_path == test_path)
3060            .collect();
3061        assert!(
3062            !test_refs.is_empty(),
3063            "Child should have test file references"
3064        );
3065
3066        // Parent's references should include child's parameter, but not test file
3067        let parent_refs = db.find_references_for_definition(parent_def);
3068        let child_param_refs: Vec<_> = parent_refs
3069            .iter()
3070            .filter(|r| r.file_path == child_conftest)
3071            .collect();
3072        let test_refs_in_parent: Vec<_> = parent_refs
3073            .iter()
3074            .filter(|r| r.file_path == test_path)
3075            .collect();
3076
3077        assert!(
3078            !child_param_refs.is_empty(),
3079            "Parent should have child parameter reference"
3080        );
3081        assert!(
3082            test_refs_in_parent.is_empty(),
3083            "Parent should NOT have test file references"
3084        );
3085
3086        // Grandparent's references should include parent's parameter, but not child's stuff
3087        let grandparent_refs = db.find_references_for_definition(grandparent_def);
3088        let parent_param_refs: Vec<_> = grandparent_refs
3089            .iter()
3090            .filter(|r| r.file_path == parent_conftest)
3091            .collect();
3092        let child_refs_in_gp: Vec<_> = grandparent_refs
3093            .iter()
3094            .filter(|r| r.file_path == child_conftest)
3095            .collect();
3096
3097        assert!(
3098            !parent_param_refs.is_empty(),
3099            "Grandparent should have parent parameter reference"
3100        );
3101        assert!(
3102            child_refs_in_gp.is_empty(),
3103            "Grandparent should NOT have child references"
3104        );
3105    }
3106
3107    #[test]
3108    fn test_fixture_hierarchy_same_file_override() {
3109        // Test that a fixture can be overridden in the same file
3110        // (less common but valid pytest pattern)
3111        let db = FixtureDatabase::new();
3112
3113        let content = r#"
3114import pytest
3115
3116@pytest.fixture
3117def base():
3118    return "base"
3119
3120@pytest.fixture
3121def base(base):
3122    return f"override_{base}"
3123
3124def test_uses_override(base):
3125    pass
3126"#;
3127        let test_path = PathBuf::from("/tmp/test/test_example.py");
3128        db.analyze_file(test_path.clone(), content);
3129
3130        let defs = db.definitions.get("base").unwrap();
3131        assert_eq!(defs.len(), 2, "Should have 2 definitions in same file");
3132
3133        println!("\nDefinitions found:");
3134        for d in defs.iter() {
3135            println!("  base at line {}", d.line);
3136        }
3137
3138        // Check usages
3139        if let Some(usages) = db.usages.get(&test_path) {
3140            println!("\nUsages found:");
3141            for u in usages.iter() {
3142                println!("  {} at line {}", u.name, u.line);
3143            }
3144        } else {
3145            println!("\nNo usages found!");
3146        }
3147
3148        // The test should resolve to the second definition (override)
3149        // Line 12 (1-indexed) = line 11 (0-indexed LSP)
3150        // Character position: "def test_uses_override(base):" - 'b' is at position 23
3151        let resolved = db.find_fixture_definition(&test_path, 11, 23);
3152
3153        println!("\nResolved: {:?}", resolved.as_ref().map(|d| d.line));
3154
3155        assert!(resolved.is_some(), "Should resolve to override definition");
3156
3157        // The second definition should be at line 9 (1-indexed)
3158        let override_def = defs.iter().find(|d| d.line == 9).unwrap();
3159        println!("Override def at line: {}", override_def.line);
3160        assert_eq!(resolved.as_ref(), Some(override_def));
3161    }
3162
3163    #[test]
3164    fn test_cursor_position_on_definition_line() {
3165        // Debug test to understand what happens at different cursor positions
3166        // on a fixture definition line with a self-referencing parameter
3167        let db = FixtureDatabase::new();
3168
3169        // Add a parent conftest with parent fixture
3170        let parent_content = r#"
3171import pytest
3172
3173@pytest.fixture
3174def cli_runner():
3175    return "parent"
3176"#;
3177        let parent_conftest = PathBuf::from("/tmp/conftest.py");
3178        db.analyze_file(parent_conftest.clone(), parent_content);
3179
3180        let content = r#"
3181import pytest
3182
3183@pytest.fixture
3184def cli_runner(cli_runner):
3185    return cli_runner
3186"#;
3187        let test_path = PathBuf::from("/tmp/test/test_example.py");
3188        db.analyze_file(test_path.clone(), content);
3189
3190        // Line 5 (1-indexed): "def cli_runner(cli_runner):"
3191        // Position 0: 'd' in def
3192        // Position 4: 'c' in cli_runner (function name)
3193        // Position 15: '('
3194        // Position 16: 'c' in cli_runner (parameter name)
3195
3196        println!("\n=== Testing character positions on line 5 ===");
3197
3198        // Check usages
3199        if let Some(usages) = db.usages.get(&test_path) {
3200            println!("\nUsages found:");
3201            for u in usages.iter() {
3202                println!(
3203                    "  {} at line {}, chars {}-{}",
3204                    u.name, u.line, u.start_char, u.end_char
3205                );
3206            }
3207        } else {
3208            println!("\nNo usages found!");
3209        }
3210
3211        // Test clicking on function name 'cli_runner' - should be treated as definition
3212        let line_content = "def cli_runner(cli_runner):";
3213        println!("\nLine content: '{}'", line_content);
3214
3215        // Position 4 = 'c' in function name cli_runner
3216        println!("\nPosition 4 (function name):");
3217        let word_at_4 = db.extract_word_at_position(line_content, 4);
3218        println!("  Word at cursor: {:?}", word_at_4);
3219        let fixture_name_at_4 = db.find_fixture_at_position(&test_path, 4, 4);
3220        println!("  find_fixture_at_position: {:?}", fixture_name_at_4);
3221        let resolved_4 = db.find_fixture_definition(&test_path, 4, 4); // Line 5 = index 4
3222        println!(
3223            "  Resolved: {:?}",
3224            resolved_4.as_ref().map(|d| (d.name.as_str(), d.line))
3225        );
3226
3227        // Position 16 = 'c' in parameter name cli_runner
3228        println!("\nPosition 16 (parameter name):");
3229        let word_at_16 = db.extract_word_at_position(line_content, 16);
3230        println!("  Word at cursor: {:?}", word_at_16);
3231
3232        // Manual check: does the usage check work?
3233        if let Some(usages) = db.usages.get(&test_path) {
3234            for usage in usages.iter() {
3235                println!("  Checking usage: {} at line {}", usage.name, usage.line);
3236                if usage.line == 5 && usage.name == "cli_runner" {
3237                    println!("    MATCH! Usage matches our position");
3238                }
3239            }
3240        }
3241
3242        let fixture_name_at_16 = db.find_fixture_at_position(&test_path, 4, 16);
3243        println!("  find_fixture_at_position: {:?}", fixture_name_at_16);
3244        let resolved_16 = db.find_fixture_definition(&test_path, 4, 16); // Line 5 = index 4
3245        println!(
3246            "  Resolved: {:?}",
3247            resolved_16.as_ref().map(|d| (d.name.as_str(), d.line))
3248        );
3249
3250        // Expected behavior:
3251        // - Position 4 (function name): should resolve to CHILD (line 5) - we're ON the definition
3252        // - Position 16 (parameter): should resolve to PARENT (line 5 in conftest) - it's a usage
3253
3254        assert_eq!(word_at_4, Some("cli_runner".to_string()));
3255        assert_eq!(word_at_16, Some("cli_runner".to_string()));
3256
3257        // Check the actual resolution
3258        println!("\n=== ACTUAL vs EXPECTED ===");
3259        println!("Position 4 (function name):");
3260        println!(
3261            "  Actual: {:?}",
3262            resolved_4.as_ref().map(|d| (&d.file_path, d.line))
3263        );
3264        println!("  Expected: test file, line 5 (the child definition itself)");
3265
3266        println!("\nPosition 16 (parameter):");
3267        println!(
3268            "  Actual: {:?}",
3269            resolved_16.as_ref().map(|d| (&d.file_path, d.line))
3270        );
3271        println!("  Expected: conftest, line 5 (the parent definition)");
3272
3273        // The BUG: both return the same thing (child at line 5)
3274        // Position 4: returning child is CORRECT (though find_fixture_definition returns None,
3275        //             main.rs falls back to get_definition_at_line which is correct)
3276        // Position 16: returning child is WRONG - should return parent (line 5 in conftest)
3277
3278        if let Some(ref def) = resolved_16 {
3279            assert_eq!(
3280                def.file_path, parent_conftest,
3281                "Parameter should resolve to parent definition"
3282            );
3283        } else {
3284            panic!("Position 16 (parameter) should resolve to parent definition");
3285        }
3286    }
3287
3288    #[test]
3289    fn test_undeclared_fixture_detection_in_test() {
3290        let db = FixtureDatabase::new();
3291
3292        // Add a fixture definition in conftest
3293        let conftest_content = r#"
3294import pytest
3295
3296@pytest.fixture
3297def my_fixture():
3298    return 42
3299"#;
3300        let conftest_path = PathBuf::from("/tmp/conftest.py");
3301        db.analyze_file(conftest_path.clone(), conftest_content);
3302
3303        // Add a test that uses the fixture without declaring it
3304        let test_content = r#"
3305def test_example():
3306    result = my_fixture.get()
3307    assert result == 42
3308"#;
3309        let test_path = PathBuf::from("/tmp/test_example.py");
3310        db.analyze_file(test_path.clone(), test_content);
3311
3312        // Check that undeclared fixture was detected
3313        let undeclared = db.get_undeclared_fixtures(&test_path);
3314        assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3315
3316        let fixture = &undeclared[0];
3317        assert_eq!(fixture.name, "my_fixture");
3318        assert_eq!(fixture.function_name, "test_example");
3319        assert_eq!(fixture.line, 3); // Line 3: "result = my_fixture.get()"
3320    }
3321
3322    #[test]
3323    fn test_undeclared_fixture_detection_in_fixture() {
3324        let db = FixtureDatabase::new();
3325
3326        // Add fixture definitions in conftest
3327        let conftest_content = r#"
3328import pytest
3329
3330@pytest.fixture
3331def base_fixture():
3332    return "base"
3333
3334@pytest.fixture
3335def helper_fixture():
3336    return "helper"
3337"#;
3338        let conftest_path = PathBuf::from("/tmp/conftest.py");
3339        db.analyze_file(conftest_path.clone(), conftest_content);
3340
3341        // Add a fixture that uses another fixture without declaring it
3342        let test_content = r#"
3343import pytest
3344
3345@pytest.fixture
3346def my_fixture(base_fixture):
3347    data = helper_fixture.value
3348    return f"{base_fixture}-{data}"
3349"#;
3350        let test_path = PathBuf::from("/tmp/test_example.py");
3351        db.analyze_file(test_path.clone(), test_content);
3352
3353        // Check that undeclared fixture was detected
3354        let undeclared = db.get_undeclared_fixtures(&test_path);
3355        assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3356
3357        let fixture = &undeclared[0];
3358        assert_eq!(fixture.name, "helper_fixture");
3359        assert_eq!(fixture.function_name, "my_fixture");
3360        assert_eq!(fixture.line, 6); // Line 6: "data = helper_fixture.value"
3361    }
3362
3363    #[test]
3364    fn test_no_false_positive_for_declared_fixtures() {
3365        let db = FixtureDatabase::new();
3366
3367        // Add a fixture definition in conftest
3368        let conftest_content = r#"
3369import pytest
3370
3371@pytest.fixture
3372def my_fixture():
3373    return 42
3374"#;
3375        let conftest_path = PathBuf::from("/tmp/conftest.py");
3376        db.analyze_file(conftest_path.clone(), conftest_content);
3377
3378        // Add a test that properly declares the fixture as a parameter
3379        let test_content = r#"
3380def test_example(my_fixture):
3381    result = my_fixture
3382    assert result == 42
3383"#;
3384        let test_path = PathBuf::from("/tmp/test_example.py");
3385        db.analyze_file(test_path.clone(), test_content);
3386
3387        // Check that no undeclared fixtures were detected
3388        let undeclared = db.get_undeclared_fixtures(&test_path);
3389        assert_eq!(
3390            undeclared.len(),
3391            0,
3392            "Should not detect any undeclared fixtures"
3393        );
3394    }
3395
3396    #[test]
3397    fn test_no_false_positive_for_non_fixtures() {
3398        let db = FixtureDatabase::new();
3399
3400        // Add a test that uses regular variables (not fixtures)
3401        let test_content = r#"
3402def test_example():
3403    my_variable = 42
3404    result = my_variable + 10
3405    assert result == 52
3406"#;
3407        let test_path = PathBuf::from("/tmp/test_example.py");
3408        db.analyze_file(test_path.clone(), test_content);
3409
3410        // Check that no undeclared fixtures were detected
3411        let undeclared = db.get_undeclared_fixtures(&test_path);
3412        assert_eq!(
3413            undeclared.len(),
3414            0,
3415            "Should not detect any undeclared fixtures"
3416        );
3417    }
3418
3419    #[test]
3420    fn test_undeclared_fixture_not_available_in_hierarchy() {
3421        let db = FixtureDatabase::new();
3422
3423        // Add a fixture in a different directory (not in hierarchy)
3424        let other_conftest = r#"
3425import pytest
3426
3427@pytest.fixture
3428def other_fixture():
3429    return "other"
3430"#;
3431        let other_path = PathBuf::from("/other/conftest.py");
3432        db.analyze_file(other_path.clone(), other_conftest);
3433
3434        // Add a test that uses a name that happens to match a fixture but isn't available
3435        let test_content = r#"
3436def test_example():
3437    result = other_fixture.value
3438    assert result == "other"
3439"#;
3440        let test_path = PathBuf::from("/tmp/test_example.py");
3441        db.analyze_file(test_path.clone(), test_content);
3442
3443        // Should not detect undeclared fixture because it's not in the hierarchy
3444        let undeclared = db.get_undeclared_fixtures(&test_path);
3445        assert_eq!(
3446            undeclared.len(),
3447            0,
3448            "Should not detect fixtures not in hierarchy"
3449        );
3450    }
3451}
3452
3453#[test]
3454fn test_undeclared_fixture_in_async_test() {
3455    let db = FixtureDatabase::new();
3456
3457    // Add fixture in same file
3458    let content = r#"
3459import pytest
3460
3461@pytest.fixture
3462def http_client():
3463    return "MockClient"
3464
3465async def test_with_undeclared():
3466    response = await http_client.query("test")
3467    assert response == "test"
3468"#;
3469    let test_path = PathBuf::from("/tmp/test_example.py");
3470    db.analyze_file(test_path.clone(), content);
3471
3472    // Check that undeclared fixture was detected
3473    let undeclared = db.get_undeclared_fixtures(&test_path);
3474
3475    println!("Found {} undeclared fixtures", undeclared.len());
3476    for u in &undeclared {
3477        println!("  - {} at line {} in {}", u.name, u.line, u.function_name);
3478    }
3479
3480    assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3481    assert_eq!(undeclared[0].name, "http_client");
3482    assert_eq!(undeclared[0].function_name, "test_with_undeclared");
3483    assert_eq!(undeclared[0].line, 9);
3484}
3485
3486#[test]
3487fn test_undeclared_fixture_in_assert_statement() {
3488    let db = FixtureDatabase::new();
3489
3490    // Add fixture in conftest
3491    let conftest_content = r#"
3492import pytest
3493
3494@pytest.fixture
3495def expected_value():
3496    return 42
3497"#;
3498    let conftest_path = PathBuf::from("/tmp/conftest.py");
3499    db.analyze_file(conftest_path.clone(), conftest_content);
3500
3501    // Test file that uses fixture in assert without declaring it
3502    let test_content = r#"
3503def test_assertion():
3504    result = calculate_value()
3505    assert result == expected_value
3506"#;
3507    let test_path = PathBuf::from("/tmp/test_example.py");
3508    db.analyze_file(test_path.clone(), test_content);
3509
3510    // Check that undeclared fixture was detected in assert
3511    let undeclared = db.get_undeclared_fixtures(&test_path);
3512
3513    assert_eq!(
3514        undeclared.len(),
3515        1,
3516        "Should detect one undeclared fixture in assert"
3517    );
3518    assert_eq!(undeclared[0].name, "expected_value");
3519    assert_eq!(undeclared[0].function_name, "test_assertion");
3520}
3521
3522#[test]
3523fn test_no_false_positive_for_local_variable() {
3524    // Problem 2: Should not warn if a local variable shadows a fixture name
3525    let db = FixtureDatabase::new();
3526
3527    // Add fixture in conftest
3528    let conftest_content = r#"
3529import pytest
3530
3531@pytest.fixture
3532def foo():
3533    return "fixture"
3534"#;
3535    let conftest_path = PathBuf::from("/tmp/conftest.py");
3536    db.analyze_file(conftest_path.clone(), conftest_content);
3537
3538    // Test file that has a local variable with the same name
3539    let test_content = r#"
3540def test_with_local_variable():
3541    foo = "local variable"
3542    result = foo.upper()
3543    assert result == "LOCAL VARIABLE"
3544"#;
3545    let test_path = PathBuf::from("/tmp/test_example.py");
3546    db.analyze_file(test_path.clone(), test_content);
3547
3548    // Should NOT detect undeclared fixture because foo is a local variable
3549    let undeclared = db.get_undeclared_fixtures(&test_path);
3550
3551    assert_eq!(
3552        undeclared.len(),
3553        0,
3554        "Should not detect undeclared fixture when name is a local variable"
3555    );
3556}
3557
3558#[test]
3559fn test_no_false_positive_for_imported_name() {
3560    // Problem 2: Should not warn if an imported name shadows a fixture name
3561    let db = FixtureDatabase::new();
3562
3563    // Add fixture in conftest
3564    let conftest_content = r#"
3565import pytest
3566
3567@pytest.fixture
3568def foo():
3569    return "fixture"
3570"#;
3571    let conftest_path = PathBuf::from("/tmp/conftest.py");
3572    db.analyze_file(conftest_path.clone(), conftest_content);
3573
3574    // Test file that imports a name
3575    let test_content = r#"
3576from mymodule import foo
3577
3578def test_with_import():
3579    result = foo.something()
3580    assert result == "value"
3581"#;
3582    let test_path = PathBuf::from("/tmp/test_example.py");
3583    db.analyze_file(test_path.clone(), test_content);
3584
3585    // Should NOT detect undeclared fixture because foo is imported
3586    let undeclared = db.get_undeclared_fixtures(&test_path);
3587
3588    assert_eq!(
3589        undeclared.len(),
3590        0,
3591        "Should not detect undeclared fixture when name is imported"
3592    );
3593}
3594
3595#[test]
3596fn test_warn_for_fixture_used_directly() {
3597    // Problem 2: SHOULD warn if trying to use a fixture defined in the same file
3598    // This is an error because fixtures must be accessed through parameters
3599    let db = FixtureDatabase::new();
3600
3601    let test_content = r#"
3602import pytest
3603
3604@pytest.fixture
3605def foo():
3606    return "fixture"
3607
3608def test_using_fixture_directly():
3609    # This is an error - fixtures must be declared as parameters
3610    result = foo.something()
3611    assert result == "value"
3612"#;
3613    let test_path = PathBuf::from("/tmp/test_example.py");
3614    db.analyze_file(test_path.clone(), test_content);
3615
3616    // SHOULD detect undeclared fixture even though foo is defined in same file
3617    let undeclared = db.get_undeclared_fixtures(&test_path);
3618
3619    assert_eq!(
3620        undeclared.len(),
3621        1,
3622        "Should detect fixture used directly without parameter declaration"
3623    );
3624    assert_eq!(undeclared[0].name, "foo");
3625    assert_eq!(undeclared[0].function_name, "test_using_fixture_directly");
3626}
3627
3628#[test]
3629fn test_no_false_positive_for_module_level_assignment() {
3630    // Should not warn if name is assigned at module level (not a fixture)
3631    let db = FixtureDatabase::new();
3632
3633    // Add fixture in conftest
3634    let conftest_content = r#"
3635import pytest
3636
3637@pytest.fixture
3638def foo():
3639    return "fixture"
3640"#;
3641    let conftest_path = PathBuf::from("/tmp/conftest.py");
3642    db.analyze_file(conftest_path.clone(), conftest_content);
3643
3644    // Test file that has a module-level assignment
3645    let test_content = r#"
3646# Module-level assignment
3647foo = SomeClass()
3648
3649def test_with_module_var():
3650    result = foo.method()
3651    assert result == "value"
3652"#;
3653    let test_path = PathBuf::from("/tmp/test_example.py");
3654    db.analyze_file(test_path.clone(), test_content);
3655
3656    // Should NOT detect undeclared fixture because foo is assigned at module level
3657    let undeclared = db.get_undeclared_fixtures(&test_path);
3658
3659    assert_eq!(
3660        undeclared.len(),
3661        0,
3662        "Should not detect undeclared fixture when name is assigned at module level"
3663    );
3664}
3665
3666#[test]
3667fn test_no_false_positive_for_function_definition() {
3668    // Should not warn if name is a regular function (not a fixture)
3669    let db = FixtureDatabase::new();
3670
3671    // Add fixture in conftest
3672    let conftest_content = r#"
3673import pytest
3674
3675@pytest.fixture
3676def foo():
3677    return "fixture"
3678"#;
3679    let conftest_path = PathBuf::from("/tmp/conftest.py");
3680    db.analyze_file(conftest_path.clone(), conftest_content);
3681
3682    // Test file that has a regular function with the same name
3683    let test_content = r#"
3684def foo():
3685    return "not a fixture"
3686
3687def test_with_function():
3688    result = foo()
3689    assert result == "not a fixture"
3690"#;
3691    let test_path = PathBuf::from("/tmp/test_example.py");
3692    db.analyze_file(test_path.clone(), test_content);
3693
3694    // Should NOT detect undeclared fixture because foo is a regular function
3695    let undeclared = db.get_undeclared_fixtures(&test_path);
3696
3697    assert_eq!(
3698        undeclared.len(),
3699        0,
3700        "Should not detect undeclared fixture when name is a regular function"
3701    );
3702}
3703
3704#[test]
3705fn test_no_false_positive_for_class_definition() {
3706    // Should not warn if name is a class
3707    let db = FixtureDatabase::new();
3708
3709    // Add fixture in conftest
3710    let conftest_content = r#"
3711import pytest
3712
3713@pytest.fixture
3714def MyClass():
3715    return "fixture"
3716"#;
3717    let conftest_path = PathBuf::from("/tmp/conftest.py");
3718    db.analyze_file(conftest_path.clone(), conftest_content);
3719
3720    // Test file that has a class with the same name
3721    let test_content = r#"
3722class MyClass:
3723    pass
3724
3725def test_with_class():
3726    obj = MyClass()
3727    assert obj is not None
3728"#;
3729    let test_path = PathBuf::from("/tmp/test_example.py");
3730    db.analyze_file(test_path.clone(), test_content);
3731
3732    // Should NOT detect undeclared fixture because MyClass is a class
3733    let undeclared = db.get_undeclared_fixtures(&test_path);
3734
3735    assert_eq!(
3736        undeclared.len(),
3737        0,
3738        "Should not detect undeclared fixture when name is a class"
3739    );
3740}
3741
3742#[test]
3743fn test_line_aware_local_variable_scope() {
3744    // Test that local variables are only considered "in scope" AFTER they're assigned
3745    let db = FixtureDatabase::new();
3746
3747    // Conftest with http_client fixture
3748    let conftest_content = r#"
3749import pytest
3750
3751@pytest.fixture
3752def http_client():
3753    return "MockClient"
3754"#;
3755    let conftest_path = PathBuf::from("/tmp/conftest.py");
3756    db.analyze_file(conftest_path.clone(), conftest_content);
3757
3758    // Test file that uses http_client before and after a local assignment
3759    let test_content = r#"async def test_example():
3760    # Line 1: http_client should be flagged (not yet assigned)
3761    result = await http_client.get("/api")
3762    # Line 3: Now we assign http_client locally
3763    http_client = "local"
3764    # Line 5: http_client should NOT be flagged (local var now)
3765    result2 = await http_client.get("/api2")
3766"#;
3767    let test_path = PathBuf::from("/tmp/test_example.py");
3768    db.analyze_file(test_path.clone(), test_content);
3769
3770    // Check for undeclared fixtures
3771    let undeclared = db.get_undeclared_fixtures(&test_path);
3772
3773    // Should only detect http_client on line 3 (usage before assignment)
3774    // NOT on line 7 (after assignment on line 5)
3775    assert_eq!(
3776        undeclared.len(),
3777        1,
3778        "Should detect http_client only before local assignment"
3779    );
3780    assert_eq!(undeclared[0].name, "http_client");
3781    // Line numbers: 1=def, 2=comment, 3=result (first usage), 4=comment, 5=assignment, 6=comment, 7=result2
3782    assert_eq!(
3783        undeclared[0].line, 3,
3784        "Should flag usage on line 3 (before assignment on line 5)"
3785    );
3786}
3787
3788#[test]
3789fn test_same_line_assignment_and_usage() {
3790    // Test that usage on the same line as assignment refers to the fixture
3791    let db = FixtureDatabase::new();
3792
3793    let conftest_content = r#"import pytest
3794
3795@pytest.fixture
3796def http_client():
3797    return "parent"
3798"#;
3799    let conftest_path = PathBuf::from("/tmp/conftest.py");
3800    db.analyze_file(conftest_path.clone(), conftest_content);
3801
3802    let test_content = r#"async def test_example():
3803    # This references the fixture on the RHS, then assigns to local var
3804    http_client = await http_client.get("/api")
3805"#;
3806    let test_path = PathBuf::from("/tmp/test_example.py");
3807    db.analyze_file(test_path.clone(), test_content);
3808
3809    let undeclared = db.get_undeclared_fixtures(&test_path);
3810
3811    // Should detect http_client on RHS (line 3) because assignment hasn't happened yet
3812    assert_eq!(undeclared.len(), 1);
3813    assert_eq!(undeclared[0].name, "http_client");
3814    assert_eq!(undeclared[0].line, 3);
3815}
3816
3817#[test]
3818fn test_no_false_positive_for_later_assignment() {
3819    // This is the actual bug we fixed - make sure local assignment later in function
3820    // doesn't prevent detection of undeclared fixture usage BEFORE the assignment
3821    let db = FixtureDatabase::new();
3822
3823    let conftest_content = r#"import pytest
3824
3825@pytest.fixture
3826def http_client():
3827    return "fixture"
3828"#;
3829    let conftest_path = PathBuf::from("/tmp/conftest.py");
3830    db.analyze_file(conftest_path.clone(), conftest_content);
3831
3832    // This was the original issue: http_client used on line 2, but assigned on line 4
3833    // Old code would see the assignment and not flag line 2
3834    let test_content = r#"async def test_example():
3835    result = await http_client.get("/api")  # Should be flagged
3836    # Now assign locally
3837    http_client = "local"
3838    # This should NOT be flagged because variable is now assigned
3839    result2 = http_client
3840"#;
3841    let test_path = PathBuf::from("/tmp/test_example.py");
3842    db.analyze_file(test_path.clone(), test_content);
3843
3844    let undeclared = db.get_undeclared_fixtures(&test_path);
3845
3846    // Should only detect one undeclared usage (line 2)
3847    assert_eq!(
3848        undeclared.len(),
3849        1,
3850        "Should detect exactly one undeclared fixture"
3851    );
3852    assert_eq!(undeclared[0].name, "http_client");
3853    assert_eq!(
3854        undeclared[0].line, 2,
3855        "Should flag usage on line 2 before assignment on line 4"
3856    );
3857}