pytest_language_server/fixtures/
resolver.rs

1//! Fixture resolution and query methods.
2//!
3//! This module contains methods for finding fixture definitions,
4//! references, and providing completion context.
5
6use super::decorators;
7use super::types::{
8    CompletionContext, FixtureDefinition, FixtureUsage, ParamInsertionInfo, UndeclaredFixture,
9};
10use super::FixtureDatabase;
11use rustpython_parser::ast::{Expr, Ranged, Stmt};
12use std::collections::HashSet;
13use std::path::Path;
14use tracing::{debug, info};
15
16impl FixtureDatabase {
17    /// Find fixture definition for a given position in a file
18    pub fn find_fixture_definition(
19        &self,
20        file_path: &Path,
21        line: u32,
22        character: u32,
23    ) -> Option<FixtureDefinition> {
24        debug!(
25            "find_fixture_definition: file={:?}, line={}, char={}",
26            file_path, line, character
27        );
28
29        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
30
31        let content = self.get_file_content(file_path)?;
32        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
33        debug!("Line content: {}", line_content);
34
35        let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
36        debug!("Word at cursor: {:?}", word_at_cursor);
37
38        // Check if we're inside a fixture definition with the same name (self-referencing)
39        let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
40
41        // First, check if this word matches any fixture usage on this line
42        if let Some(usages) = self.usages.get(file_path) {
43            for usage in usages.iter() {
44                if usage.line == target_line && usage.name == word_at_cursor {
45                    let cursor_pos = character as usize;
46                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
47                        debug!(
48                            "Cursor at {} is within usage range {}-{}: {}",
49                            cursor_pos, usage.start_char, usage.end_char, usage.name
50                        );
51                        info!("Found fixture usage at cursor position: {}", usage.name);
52
53                        // If we're in a fixture definition with the same name, skip it
54                        if let Some(ref current_def) = current_fixture_def {
55                            if current_def.name == word_at_cursor {
56                                info!(
57                                    "Self-referencing fixture detected, finding parent definition"
58                                );
59                                return self.find_closest_definition_excluding(
60                                    file_path,
61                                    &usage.name,
62                                    Some(current_def),
63                                );
64                            }
65                        }
66
67                        return self.find_closest_definition(file_path, &usage.name);
68                    }
69                }
70            }
71        }
72
73        debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
74        None
75    }
76
77    /// Get the fixture definition at a specific line (if the line is a fixture definition)
78    fn get_fixture_definition_at_line(
79        &self,
80        file_path: &Path,
81        line: usize,
82    ) -> Option<FixtureDefinition> {
83        for entry in self.definitions.iter() {
84            for def in entry.value().iter() {
85                if def.file_path == file_path && def.line == line {
86                    return Some(def.clone());
87                }
88            }
89        }
90        None
91    }
92
93    /// Find fixture definition at a given position, checking both usages and definitions.
94    ///
95    /// This is useful for Call Hierarchy where we want to work on both fixture definition
96    /// lines and fixture usage sites.
97    pub fn find_fixture_or_definition_at_position(
98        &self,
99        file_path: &Path,
100        line: u32,
101        character: u32,
102    ) -> Option<FixtureDefinition> {
103        // First try to find a usage and resolve it to definition
104        if let Some(def) = self.find_fixture_definition(file_path, line, character) {
105            return Some(def);
106        }
107
108        // If not a usage, check if we're on a fixture definition line
109        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
110        let content = self.get_file_content(file_path)?;
111        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
112        let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
113
114        // Check if this word matches a fixture definition at this line
115        if let Some(definitions) = self.definitions.get(&word_at_cursor) {
116            for def in definitions.iter() {
117                if def.file_path == file_path && def.line == target_line {
118                    // Verify cursor is within the fixture name
119                    if character as usize >= def.start_char && (character as usize) < def.end_char {
120                        return Some(def.clone());
121                    }
122                }
123            }
124        }
125
126        None
127    }
128
129    /// Public method to get the fixture definition at a specific line and name
130    pub fn get_definition_at_line(
131        &self,
132        file_path: &Path,
133        line: usize,
134        fixture_name: &str,
135    ) -> Option<FixtureDefinition> {
136        if let Some(definitions) = self.definitions.get(fixture_name) {
137            for def in definitions.iter() {
138                if def.file_path == file_path && def.line == line {
139                    return Some(def.clone());
140                }
141            }
142        }
143        None
144    }
145
146    /// Find the closest fixture definition based on pytest priority rules.
147    pub(crate) fn find_closest_definition(
148        &self,
149        file_path: &Path,
150        fixture_name: &str,
151    ) -> Option<FixtureDefinition> {
152        self.find_closest_definition_with_filter(file_path, fixture_name, |_| true)
153    }
154
155    /// Find the closest definition, excluding a specific definition.
156    pub(crate) fn find_closest_definition_excluding(
157        &self,
158        file_path: &Path,
159        fixture_name: &str,
160        exclude: Option<&FixtureDefinition>,
161    ) -> Option<FixtureDefinition> {
162        self.find_closest_definition_with_filter(file_path, fixture_name, |def| {
163            if let Some(excluded) = exclude {
164                def != excluded
165            } else {
166                true
167            }
168        })
169    }
170
171    /// Internal helper that implements pytest priority rules with a custom filter.
172    /// Priority order:
173    /// 1. Same file (highest priority, last definition wins)
174    /// 2. Closest conftest.py in parent directories
175    /// 3. Third-party fixtures from site-packages
176    fn find_closest_definition_with_filter<F>(
177        &self,
178        file_path: &Path,
179        fixture_name: &str,
180        filter: F,
181    ) -> Option<FixtureDefinition>
182    where
183        F: Fn(&FixtureDefinition) -> bool,
184    {
185        let definitions = self.definitions.get(fixture_name)?;
186
187        // Priority 1: Same file (highest priority)
188        debug!(
189            "Checking for fixture {} in same file: {:?}",
190            fixture_name, file_path
191        );
192
193        if let Some(last_def) = definitions
194            .iter()
195            .filter(|def| def.file_path == file_path && filter(def))
196            .max_by_key(|def| def.line)
197        {
198            info!(
199                "Found fixture {} in same file at line {}",
200                fixture_name, last_def.line
201            );
202            return Some(last_def.clone());
203        }
204
205        // Priority 2: Search upward through conftest.py files
206        let mut current_dir = file_path.parent()?;
207
208        debug!(
209            "Searching for fixture {} in conftest.py files starting from {:?}",
210            fixture_name, current_dir
211        );
212        loop {
213            let conftest_path = current_dir.join("conftest.py");
214            debug!("  Checking conftest.py at: {:?}", conftest_path);
215
216            for def in definitions.iter() {
217                if def.file_path == conftest_path && filter(def) {
218                    info!(
219                        "Found fixture {} in conftest.py: {:?}",
220                        fixture_name, conftest_path
221                    );
222                    return Some(def.clone());
223                }
224            }
225
226            match current_dir.parent() {
227                Some(parent) => current_dir = parent,
228                None => break,
229            }
230        }
231
232        // Priority 3: Third-party fixtures (site-packages)
233        debug!(
234            "No fixture {} found in conftest hierarchy, checking third-party",
235            fixture_name
236        );
237        for def in definitions.iter() {
238            if def.is_third_party && filter(def) {
239                info!(
240                    "Found third-party fixture {} in site-packages: {:?}",
241                    fixture_name, def.file_path
242                );
243                return Some(def.clone());
244            }
245        }
246
247        debug!(
248            "No fixture {} found in scope for {:?}",
249            fixture_name, file_path
250        );
251        None
252    }
253
254    /// Find the fixture name at a given position (either definition or usage)
255    pub fn find_fixture_at_position(
256        &self,
257        file_path: &Path,
258        line: u32,
259        character: u32,
260    ) -> Option<String> {
261        let target_line = (line + 1) as usize;
262
263        debug!(
264            "find_fixture_at_position: file={:?}, line={}, char={}",
265            file_path, target_line, character
266        );
267
268        let content = self.get_file_content(file_path)?;
269        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
270        debug!("Line content: {}", line_content);
271
272        let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
273        debug!("Word at cursor: {:?}", word_at_cursor);
274
275        // Check if this word matches any fixture usage on this line
276        if let Some(usages) = self.usages.get(file_path) {
277            for usage in usages.iter() {
278                if usage.line == target_line {
279                    let cursor_pos = character as usize;
280                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
281                        debug!(
282                            "Cursor at {} is within usage range {}-{}: {}",
283                            cursor_pos, usage.start_char, usage.end_char, usage.name
284                        );
285                        info!("Found fixture usage at cursor position: {}", usage.name);
286                        return Some(usage.name.clone());
287                    }
288                }
289            }
290        }
291
292        // Check if we're on a fixture definition line
293        for entry in self.definitions.iter() {
294            for def in entry.value().iter() {
295                if def.file_path == file_path && def.line == target_line {
296                    if let Some(ref word) = word_at_cursor {
297                        if word == &def.name {
298                            info!(
299                                "Found fixture definition name at cursor position: {}",
300                                def.name
301                            );
302                            return Some(def.name.clone());
303                        }
304                    }
305                }
306            }
307        }
308
309        debug!("No fixture found at cursor position");
310        None
311    }
312
313    /// Extract the word at a given character position in a line
314    pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
315        super::string_utils::extract_word_at_position(line, character)
316    }
317
318    /// Find all references (usages) of a fixture by name
319    pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
320        info!("Finding all references for fixture: {}", fixture_name);
321
322        let mut all_references = Vec::new();
323
324        for entry in self.usages.iter() {
325            let file_path = entry.key();
326            let usages = entry.value();
327
328            for usage in usages.iter() {
329                if usage.name == fixture_name {
330                    debug!(
331                        "Found reference to {} in {:?} at line {}",
332                        fixture_name, file_path, usage.line
333                    );
334                    all_references.push(usage.clone());
335                }
336            }
337        }
338
339        info!(
340            "Found {} total references for fixture: {}",
341            all_references.len(),
342            fixture_name
343        );
344        all_references
345    }
346
347    /// Find all references that resolve to a specific fixture definition.
348    /// Uses the usage_by_fixture reverse index for O(m) lookup where m = usages of this fixture,
349    /// instead of O(n) iteration over all usages.
350    pub fn find_references_for_definition(
351        &self,
352        definition: &FixtureDefinition,
353    ) -> Vec<FixtureUsage> {
354        info!(
355            "Finding references for specific definition: {} at {:?}:{}",
356            definition.name, definition.file_path, definition.line
357        );
358
359        let mut matching_references = Vec::new();
360
361        // Use reverse index for O(m) lookup instead of O(n) iteration over all usages
362        let Some(usages_for_fixture) = self.usage_by_fixture.get(&definition.name) else {
363            info!("No references found for fixture: {}", definition.name);
364            return matching_references;
365        };
366
367        for (file_path, usage) in usages_for_fixture.iter() {
368            let fixture_def_at_line = self.get_fixture_definition_at_line(file_path, usage.line);
369
370            let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
371                if current_def.name == usage.name {
372                    debug!(
373                        "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
374                        file_path, usage.line, current_def.line
375                    );
376                    self.find_closest_definition_excluding(
377                        file_path,
378                        &usage.name,
379                        Some(current_def),
380                    )
381                } else {
382                    self.find_closest_definition(file_path, &usage.name)
383                }
384            } else {
385                self.find_closest_definition(file_path, &usage.name)
386            };
387
388            if let Some(resolved_def) = resolved_def {
389                if resolved_def == *definition {
390                    debug!(
391                        "Usage at {:?}:{} resolves to our definition",
392                        file_path, usage.line
393                    );
394                    matching_references.push(usage.clone());
395                } else {
396                    debug!(
397                        "Usage at {:?}:{} resolves to different definition at {:?}:{}",
398                        file_path, usage.line, resolved_def.file_path, resolved_def.line
399                    );
400                }
401            }
402        }
403
404        info!(
405            "Found {} references that resolve to this specific definition",
406            matching_references.len()
407        );
408        matching_references
409    }
410
411    /// Get all undeclared fixture usages for a file
412    pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
413        self.undeclared_fixtures
414            .get(file_path)
415            .map(|entry| entry.value().clone())
416            .unwrap_or_default()
417    }
418
419    /// Get all available fixtures for a given file.
420    /// Results are cached with version-based invalidation for performance.
421    /// Returns Arc to avoid cloning the potentially large Vec on cache hits.
422    pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
423        use std::sync::Arc;
424
425        // Canonicalize path for consistent cache keys
426        let file_path = self.get_canonical_path(file_path.to_path_buf());
427
428        // Check cache first
429        let current_version = self
430            .definitions_version
431            .load(std::sync::atomic::Ordering::SeqCst);
432
433        if let Some(cached) = self.available_fixtures_cache.get(&file_path) {
434            let (cached_version, cached_fixtures) = cached.value();
435            if *cached_version == current_version {
436                // Return cloned Vec from Arc (cheap reference count increment)
437                return cached_fixtures.as_ref().clone();
438            }
439        }
440
441        // Compute available fixtures
442        let available_fixtures = self.compute_available_fixtures(&file_path);
443
444        // Store in cache
445        self.available_fixtures_cache.insert(
446            file_path,
447            (current_version, Arc::new(available_fixtures.clone())),
448        );
449
450        available_fixtures
451    }
452
453    /// Internal method to compute available fixtures without caching.
454    fn compute_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
455        let mut available_fixtures = Vec::new();
456        let mut seen_names = HashSet::new();
457
458        // Priority 1: Fixtures in the same file
459        for entry in self.definitions.iter() {
460            let fixture_name = entry.key();
461            for def in entry.value().iter() {
462                if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
463                    available_fixtures.push(def.clone());
464                    seen_names.insert(fixture_name.clone());
465                }
466            }
467        }
468
469        // Priority 2: Fixtures in conftest.py files
470        if let Some(mut current_dir) = file_path.parent() {
471            loop {
472                let conftest_path = current_dir.join("conftest.py");
473
474                for entry in self.definitions.iter() {
475                    let fixture_name = entry.key();
476                    for def in entry.value().iter() {
477                        if def.file_path == conftest_path
478                            && !seen_names.contains(fixture_name.as_str())
479                        {
480                            available_fixtures.push(def.clone());
481                            seen_names.insert(fixture_name.clone());
482                        }
483                    }
484                }
485
486                match current_dir.parent() {
487                    Some(parent) => current_dir = parent,
488                    None => break,
489                }
490            }
491        }
492
493        // Priority 3: Third-party fixtures from site-packages
494        for entry in self.definitions.iter() {
495            let fixture_name = entry.key();
496            for def in entry.value().iter() {
497                if def.is_third_party && !seen_names.contains(fixture_name.as_str()) {
498                    available_fixtures.push(def.clone());
499                    seen_names.insert(fixture_name.clone());
500                }
501            }
502        }
503
504        available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
505        available_fixtures
506    }
507
508    /// Get the completion context for a given position
509    pub fn get_completion_context(
510        &self,
511        file_path: &Path,
512        line: u32,
513        character: u32,
514    ) -> Option<CompletionContext> {
515        let content = self.get_file_content(file_path)?;
516        let target_line = (line + 1) as usize;
517        let line_index = self.get_line_index(file_path, &content);
518
519        let parsed = self.get_parsed_ast(file_path, &content)?;
520
521        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
522            // First check if we're inside a decorator
523            if let Some(ctx) =
524                self.check_decorator_context(&module.body, &content, target_line, &line_index)
525            {
526                return Some(ctx);
527            }
528
529            // Then check for function context
530            return self.get_function_completion_context(
531                &module.body,
532                &content,
533                target_line,
534                character as usize,
535                &line_index,
536            );
537        }
538
539        None
540    }
541
542    /// Check if the cursor is inside a decorator that needs fixture completions
543    fn check_decorator_context(
544        &self,
545        stmts: &[Stmt],
546        _content: &str,
547        target_line: usize,
548        line_index: &[usize],
549    ) -> Option<CompletionContext> {
550        for stmt in stmts {
551            let decorator_list = match stmt {
552                Stmt::FunctionDef(f) => &f.decorator_list,
553                Stmt::AsyncFunctionDef(f) => &f.decorator_list,
554                Stmt::ClassDef(c) => &c.decorator_list,
555                _ => continue,
556            };
557
558            for decorator in decorator_list {
559                let dec_start_line =
560                    self.get_line_from_offset(decorator.range().start().to_usize(), line_index);
561                let dec_end_line =
562                    self.get_line_from_offset(decorator.range().end().to_usize(), line_index);
563
564                if target_line >= dec_start_line && target_line <= dec_end_line {
565                    if decorators::is_usefixtures_decorator(decorator) {
566                        return Some(CompletionContext::UsefixuturesDecorator);
567                    }
568                    if decorators::is_parametrize_decorator(decorator) {
569                        return Some(CompletionContext::ParametrizeIndirect);
570                    }
571                }
572            }
573
574            // Recursively check class bodies
575            if let Stmt::ClassDef(class_def) = stmt {
576                if let Some(ctx) =
577                    self.check_decorator_context(&class_def.body, _content, target_line, line_index)
578                {
579                    return Some(ctx);
580                }
581            }
582        }
583
584        None
585    }
586
587    /// Get completion context when cursor is inside a function
588    fn get_function_completion_context(
589        &self,
590        stmts: &[Stmt],
591        content: &str,
592        target_line: usize,
593        target_char: usize,
594        line_index: &[usize],
595    ) -> Option<CompletionContext> {
596        for stmt in stmts {
597            match stmt {
598                Stmt::FunctionDef(func_def) => {
599                    if let Some(ctx) = self.get_func_context(
600                        &func_def.name,
601                        &func_def.decorator_list,
602                        &func_def.args,
603                        func_def.range,
604                        content,
605                        target_line,
606                        target_char,
607                        line_index,
608                    ) {
609                        return Some(ctx);
610                    }
611                }
612                Stmt::AsyncFunctionDef(func_def) => {
613                    if let Some(ctx) = self.get_func_context(
614                        &func_def.name,
615                        &func_def.decorator_list,
616                        &func_def.args,
617                        func_def.range,
618                        content,
619                        target_line,
620                        target_char,
621                        line_index,
622                    ) {
623                        return Some(ctx);
624                    }
625                }
626                Stmt::ClassDef(class_def) => {
627                    if let Some(ctx) = self.get_function_completion_context(
628                        &class_def.body,
629                        content,
630                        target_line,
631                        target_char,
632                        line_index,
633                    ) {
634                        return Some(ctx);
635                    }
636                }
637                _ => {}
638            }
639        }
640
641        None
642    }
643
644    /// Helper to get function completion context
645    #[allow(clippy::too_many_arguments)]
646    fn get_func_context(
647        &self,
648        func_name: &rustpython_parser::ast::Identifier,
649        decorator_list: &[Expr],
650        args: &rustpython_parser::ast::Arguments,
651        range: rustpython_parser::text_size::TextRange,
652        content: &str,
653        target_line: usize,
654        _target_char: usize,
655        line_index: &[usize],
656    ) -> Option<CompletionContext> {
657        let func_start_line = self.get_line_from_offset(range.start().to_usize(), line_index);
658        let func_end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
659
660        if target_line < func_start_line || target_line > func_end_line {
661            return None;
662        }
663
664        let is_fixture = decorator_list.iter().any(decorators::is_fixture_decorator);
665        let is_test = func_name.as_str().starts_with("test_");
666
667        if !is_test && !is_fixture {
668            return None;
669        }
670
671        // Collect all parameters
672        let params: Vec<String> = FixtureDatabase::all_args(args)
673            .map(|arg| arg.def.arg.to_string())
674            .collect();
675
676        // Find the line where the function signature ends
677        let lines: Vec<&str> = content.lines().collect();
678
679        let mut sig_end_line = func_start_line;
680        for (i, line) in lines
681            .iter()
682            .enumerate()
683            .skip(func_start_line.saturating_sub(1))
684        {
685            if line.contains("):") {
686                sig_end_line = i + 1;
687                break;
688            }
689            if i + 1 > func_start_line + 10 {
690                break;
691            }
692        }
693
694        let in_signature = target_line <= sig_end_line;
695
696        let context = if in_signature {
697            CompletionContext::FunctionSignature {
698                function_name: func_name.to_string(),
699                function_line: func_start_line,
700                is_fixture,
701                declared_params: params,
702            }
703        } else {
704            CompletionContext::FunctionBody {
705                function_name: func_name.to_string(),
706                function_line: func_start_line,
707                is_fixture,
708                declared_params: params,
709            }
710        };
711
712        Some(context)
713    }
714
715    /// Get information about where to insert a new parameter in a function signature
716    pub fn get_function_param_insertion_info(
717        &self,
718        file_path: &Path,
719        function_line: usize,
720    ) -> Option<ParamInsertionInfo> {
721        let content = self.get_file_content(file_path)?;
722        let lines: Vec<&str> = content.lines().collect();
723
724        for i in (function_line.saturating_sub(1))..lines.len().min(function_line + 10) {
725            let line = lines[i];
726            if let Some(paren_pos) = line.find("):") {
727                let has_params = if let Some(open_pos) = line.find('(') {
728                    if open_pos < paren_pos {
729                        let params_section = &line[open_pos + 1..paren_pos];
730                        !params_section.trim().is_empty()
731                    } else {
732                        true
733                    }
734                } else {
735                    let before_close = &line[..paren_pos];
736                    if !before_close.trim().is_empty() {
737                        true
738                    } else {
739                        let mut found_params = false;
740                        for prev_line in lines.iter().take(i).skip(function_line.saturating_sub(1))
741                        {
742                            if prev_line.contains('(') {
743                                if let Some(open_pos) = prev_line.find('(') {
744                                    let after_open = &prev_line[open_pos + 1..];
745                                    if !after_open.trim().is_empty() {
746                                        found_params = true;
747                                        break;
748                                    }
749                                }
750                            } else if !prev_line.trim().is_empty() {
751                                found_params = true;
752                                break;
753                            }
754                        }
755                        found_params
756                    }
757                };
758
759                return Some(ParamInsertionInfo {
760                    line: i + 1,
761                    char_pos: paren_pos,
762                    needs_comma: has_params,
763                });
764            }
765        }
766
767        None
768    }
769
770    /// Check if a position is inside a test or fixture function (parameter or body)
771    /// Returns Some((function_name, is_fixture, declared_params)) if inside a function
772    #[allow(dead_code)] // Used in tests
773    #[allow(dead_code)] // Used in tests
774    pub fn is_inside_function(
775        &self,
776        file_path: &Path,
777        line: u32,
778        character: u32,
779    ) -> Option<(String, bool, Vec<String>)> {
780        // Try cache first, then file system
781        let content = self.get_file_content(file_path)?;
782
783        let target_line = (line + 1) as usize; // Convert to 1-based
784
785        // Parse the file (using cached AST)
786        let parsed = self.get_parsed_ast(file_path, &content)?;
787
788        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
789            return self.find_enclosing_function(
790                &module.body,
791                &content,
792                target_line,
793                character as usize,
794            );
795        }
796
797        None
798    }
799
800    #[allow(dead_code)]
801    fn find_enclosing_function(
802        &self,
803        stmts: &[Stmt],
804        content: &str,
805        target_line: usize,
806        _target_char: usize,
807    ) -> Option<(String, bool, Vec<String>)> {
808        let line_index = Self::build_line_index(content);
809
810        for stmt in stmts {
811            match stmt {
812                Stmt::FunctionDef(func_def) => {
813                    let func_start_line =
814                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
815                    let func_end_line =
816                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
817
818                    // Check if target is within this function's range
819                    if target_line >= func_start_line && target_line <= func_end_line {
820                        let is_fixture = func_def
821                            .decorator_list
822                            .iter()
823                            .any(decorators::is_fixture_decorator);
824                        let is_test = func_def.name.starts_with("test_");
825
826                        // Only return if it's a test or fixture
827                        if is_test || is_fixture {
828                            let params: Vec<String> = func_def
829                                .args
830                                .args
831                                .iter()
832                                .map(|arg| arg.def.arg.to_string())
833                                .collect();
834
835                            return Some((func_def.name.to_string(), is_fixture, params));
836                        }
837                    }
838                }
839                Stmt::AsyncFunctionDef(func_def) => {
840                    let func_start_line =
841                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
842                    let func_end_line =
843                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
844
845                    if target_line >= func_start_line && target_line <= func_end_line {
846                        let is_fixture = func_def
847                            .decorator_list
848                            .iter()
849                            .any(decorators::is_fixture_decorator);
850                        let is_test = func_def.name.starts_with("test_");
851
852                        if is_test || is_fixture {
853                            let params: Vec<String> = func_def
854                                .args
855                                .args
856                                .iter()
857                                .map(|arg| arg.def.arg.to_string())
858                                .collect();
859
860                            return Some((func_def.name.to_string(), is_fixture, params));
861                        }
862                    }
863                }
864                _ => {}
865            }
866        }
867
868        None
869    }
870
871    // ============ Cycle Detection ============
872
873    /// Detect circular dependencies in fixtures with caching.
874    /// Results are cached and only recomputed when definitions change.
875    /// Returns Arc to avoid cloning the potentially large Vec.
876    pub fn detect_fixture_cycles(&self) -> std::sync::Arc<Vec<super::types::FixtureCycle>> {
877        use std::sync::Arc;
878
879        let current_version = self
880            .definitions_version
881            .load(std::sync::atomic::Ordering::SeqCst);
882
883        // Check cache first
884        if let Some(cached) = self.cycle_cache.get(&()) {
885            let (cached_version, cached_cycles) = cached.value();
886            if *cached_version == current_version {
887                return Arc::clone(cached_cycles);
888            }
889        }
890
891        // Compute cycles
892        let cycles = Arc::new(self.compute_fixture_cycles());
893
894        // Store in cache
895        self.cycle_cache
896            .insert((), (current_version, Arc::clone(&cycles)));
897
898        cycles
899    }
900
901    /// Actually compute fixture cycles using iterative DFS (Tarjan-like approach).
902    /// Uses iterative algorithm to avoid stack overflow on deep dependency graphs.
903    fn compute_fixture_cycles(&self) -> Vec<super::types::FixtureCycle> {
904        use super::types::FixtureCycle;
905        use std::collections::HashMap;
906
907        // Build dependency graph: fixture_name -> dependencies (only known fixtures)
908        let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
909        let mut fixture_defs: HashMap<String, FixtureDefinition> = HashMap::new();
910
911        for entry in self.definitions.iter() {
912            let fixture_name = entry.key().clone();
913            if let Some(def) = entry.value().first() {
914                fixture_defs.insert(fixture_name.clone(), def.clone());
915                // Only include dependencies that are known fixtures
916                let valid_deps: Vec<String> = def
917                    .dependencies
918                    .iter()
919                    .filter(|d| self.definitions.contains_key(*d))
920                    .cloned()
921                    .collect();
922                dep_graph.insert(fixture_name, valid_deps);
923            }
924        }
925
926        let mut cycles = Vec::new();
927        let mut visited: HashSet<String> = HashSet::new();
928        let mut seen_cycles: HashSet<String> = HashSet::new(); // Deduplicate cycles
929
930        // Iterative DFS using explicit stack
931        for start_fixture in dep_graph.keys() {
932            if visited.contains(start_fixture) {
933                continue;
934            }
935
936            // Stack entries: (fixture_name, iterator_index, path_to_here)
937            let mut stack: Vec<(String, usize, Vec<String>)> =
938                vec![(start_fixture.clone(), 0, vec![])];
939            let mut rec_stack: HashSet<String> = HashSet::new();
940
941            while let Some((current, idx, mut path)) = stack.pop() {
942                if idx == 0 {
943                    // First time visiting this node
944                    if rec_stack.contains(&current) {
945                        // Found a cycle
946                        let cycle_start_idx = path.iter().position(|f| f == &current).unwrap_or(0);
947                        let mut cycle_path: Vec<String> = path[cycle_start_idx..].to_vec();
948                        cycle_path.push(current.clone());
949
950                        // Create a canonical key for deduplication (sorted cycle representation)
951                        let mut cycle_key: Vec<String> =
952                            cycle_path[..cycle_path.len() - 1].to_vec();
953                        cycle_key.sort();
954                        let cycle_key_str = cycle_key.join(",");
955
956                        if !seen_cycles.contains(&cycle_key_str) {
957                            seen_cycles.insert(cycle_key_str);
958                            if let Some(fixture_def) = fixture_defs.get(&current) {
959                                cycles.push(FixtureCycle {
960                                    cycle_path,
961                                    fixture: fixture_def.clone(),
962                                });
963                            }
964                        }
965                        continue;
966                    }
967
968                    rec_stack.insert(current.clone());
969                    path.push(current.clone());
970                }
971
972                // Get dependencies for current node
973                let deps = match dep_graph.get(&current) {
974                    Some(d) => d,
975                    None => {
976                        rec_stack.remove(&current);
977                        continue;
978                    }
979                };
980
981                if idx < deps.len() {
982                    // Push current back with next index
983                    stack.push((current.clone(), idx + 1, path.clone()));
984
985                    let dep = &deps[idx];
986                    if rec_stack.contains(dep) {
987                        // Found a cycle through this dependency
988                        let cycle_start_idx = path.iter().position(|f| f == dep).unwrap_or(0);
989                        let mut cycle_path: Vec<String> = path[cycle_start_idx..].to_vec();
990                        cycle_path.push(dep.clone());
991
992                        let mut cycle_key: Vec<String> =
993                            cycle_path[..cycle_path.len() - 1].to_vec();
994                        cycle_key.sort();
995                        let cycle_key_str = cycle_key.join(",");
996
997                        if !seen_cycles.contains(&cycle_key_str) {
998                            seen_cycles.insert(cycle_key_str);
999                            if let Some(fixture_def) = fixture_defs.get(dep) {
1000                                cycles.push(FixtureCycle {
1001                                    cycle_path,
1002                                    fixture: fixture_def.clone(),
1003                                });
1004                            }
1005                        }
1006                    } else if !visited.contains(dep) {
1007                        // Explore this dependency
1008                        stack.push((dep.clone(), 0, path.clone()));
1009                    }
1010                } else {
1011                    // Done with this node
1012                    visited.insert(current.clone());
1013                    rec_stack.remove(&current);
1014                }
1015            }
1016        }
1017
1018        cycles
1019    }
1020
1021    /// Detect cycles for fixtures in a specific file.
1022    /// Returns cycles where the first fixture in the cycle is defined in the given file.
1023    /// Uses cached cycle detection results for efficiency.
1024    pub fn detect_fixture_cycles_in_file(
1025        &self,
1026        file_path: &Path,
1027    ) -> Vec<super::types::FixtureCycle> {
1028        let all_cycles = self.detect_fixture_cycles();
1029        all_cycles
1030            .iter()
1031            .filter(|cycle| cycle.fixture.file_path == file_path)
1032            .cloned()
1033            .collect()
1034    }
1035
1036    // ============ Scope Validation ============
1037
1038    /// Detect scope mismatches where a broader-scoped fixture depends on a narrower-scoped fixture.
1039    /// For example, a session-scoped fixture depending on a function-scoped fixture.
1040    /// Returns mismatches for fixtures defined in the given file.
1041    pub fn detect_scope_mismatches_in_file(
1042        &self,
1043        file_path: &Path,
1044    ) -> Vec<super::types::ScopeMismatch> {
1045        use super::types::ScopeMismatch;
1046
1047        let mut mismatches = Vec::new();
1048
1049        // Get fixtures defined in this file
1050        let Some(fixture_names) = self.file_definitions.get(file_path) else {
1051            return mismatches;
1052        };
1053
1054        for fixture_name in fixture_names.iter() {
1055            // Get the fixture definition
1056            let Some(definitions) = self.definitions.get(fixture_name) else {
1057                continue;
1058            };
1059
1060            // Find the definition in this file
1061            let Some(fixture_def) = definitions.iter().find(|d| d.file_path == file_path) else {
1062                continue;
1063            };
1064
1065            // Check each dependency
1066            for dep_name in &fixture_def.dependencies {
1067                // Find the dependency's definition (use resolution logic to get correct one)
1068                if let Some(dep_definitions) = self.definitions.get(dep_name) {
1069                    // Find best matching definition for the dependency
1070                    // Use the first one (most local) - matches cycle detection behavior
1071                    if let Some(dep_def) = dep_definitions.first() {
1072                        // Check if scope mismatch: fixture has broader scope than dependency
1073                        // FixtureScope is ordered: Function < Class < Module < Package < Session
1074                        if fixture_def.scope > dep_def.scope {
1075                            mismatches.push(ScopeMismatch {
1076                                fixture: fixture_def.clone(),
1077                                dependency: dep_def.clone(),
1078                            });
1079                        }
1080                    }
1081                }
1082            }
1083        }
1084
1085        mismatches
1086    }
1087
1088    /// Resolve a fixture by name for a given file using priority rules.
1089    ///
1090    /// Returns the best matching FixtureDefinition based on pytest's
1091    /// fixture shadowing rules: same file > conftest hierarchy > third-party.
1092    pub fn resolve_fixture_for_file(
1093        &self,
1094        file_path: &Path,
1095        fixture_name: &str,
1096    ) -> Option<FixtureDefinition> {
1097        let definitions = self.definitions.get(fixture_name)?;
1098
1099        // Priority 1: Same file
1100        if let Some(def) = definitions.iter().find(|d| d.file_path == file_path) {
1101            return Some(def.clone());
1102        }
1103
1104        // Priority 2: conftest.py in parent directories (closest first)
1105        let file_path = self.get_canonical_path(file_path.to_path_buf());
1106        let mut best_conftest: Option<&FixtureDefinition> = None;
1107        let mut best_depth = usize::MAX;
1108
1109        for def in definitions.iter() {
1110            if def.is_third_party {
1111                continue;
1112            }
1113            if def.file_path.ends_with("conftest.py") {
1114                if let Some(parent) = def.file_path.parent() {
1115                    if file_path.starts_with(parent) {
1116                        let depth = parent.components().count();
1117                        if depth > best_depth {
1118                            // Deeper = closer conftest
1119                            best_conftest = Some(def);
1120                            best_depth = depth;
1121                        } else if best_conftest.is_none() {
1122                            best_conftest = Some(def);
1123                            best_depth = depth;
1124                        }
1125                    }
1126                }
1127            }
1128        }
1129
1130        if let Some(def) = best_conftest {
1131            return Some(def.clone());
1132        }
1133
1134        // Priority 3: Third-party (site-packages)
1135        if let Some(def) = definitions.iter().find(|d| d.is_third_party) {
1136            return Some(def.clone());
1137        }
1138
1139        // Fallback: first definition
1140        definitions.first().cloned()
1141    }
1142
1143    /// Find the name of the function/fixture containing a given line.
1144    ///
1145    /// Used for call hierarchy to identify callers.
1146    pub fn find_containing_function(&self, file_path: &Path, line: usize) -> Option<String> {
1147        let content = self.get_file_content(file_path)?;
1148
1149        // Use cached AST to avoid re-parsing
1150        let parsed = self.get_parsed_ast(file_path, &content)?;
1151
1152        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
1153            // Use cached line index for position calculations
1154            let line_index = self.get_line_index(file_path, &content);
1155
1156            for stmt in &module.body {
1157                if let Some(name) = self.find_function_containing_line(stmt, line, &line_index) {
1158                    return Some(name);
1159                }
1160            }
1161        }
1162
1163        None
1164    }
1165
1166    /// Recursively search for a function containing the given line.
1167    fn find_function_containing_line(
1168        &self,
1169        stmt: &Stmt,
1170        target_line: usize,
1171        line_index: &[usize],
1172    ) -> Option<String> {
1173        match stmt {
1174            Stmt::FunctionDef(func_def) => {
1175                let start_line =
1176                    self.get_line_from_offset(func_def.range.start().to_usize(), line_index);
1177                let end_line =
1178                    self.get_line_from_offset(func_def.range.end().to_usize(), line_index);
1179
1180                if target_line >= start_line && target_line <= end_line {
1181                    return Some(func_def.name.to_string());
1182                }
1183            }
1184            Stmt::AsyncFunctionDef(func_def) => {
1185                let start_line =
1186                    self.get_line_from_offset(func_def.range.start().to_usize(), line_index);
1187                let end_line =
1188                    self.get_line_from_offset(func_def.range.end().to_usize(), line_index);
1189
1190                if target_line >= start_line && target_line <= end_line {
1191                    return Some(func_def.name.to_string());
1192                }
1193            }
1194            Stmt::ClassDef(class_def) => {
1195                // Check methods inside the class
1196                for class_stmt in &class_def.body {
1197                    if let Some(name) =
1198                        self.find_function_containing_line(class_stmt, target_line, line_index)
1199                    {
1200                        return Some(name);
1201                    }
1202                }
1203            }
1204            _ => {}
1205        }
1206        None
1207    }
1208}