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