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