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