Skip to main content

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, FixtureScope, FixtureUsage, ParamInsertionInfo,
9    UndeclaredFixture,
10};
11use super::FixtureDatabase;
12use rustpython_parser::ast::{Expr, Ranged, Stmt};
13use std::collections::HashSet;
14use std::path::Path;
15use tracing::{debug, info};
16
17impl FixtureDatabase {
18    /// Find fixture definition for a given position in a file
19    pub fn find_fixture_definition(
20        &self,
21        file_path: &Path,
22        line: u32,
23        character: u32,
24    ) -> Option<FixtureDefinition> {
25        debug!(
26            "find_fixture_definition: file={:?}, line={}, char={}",
27            file_path, line, character
28        );
29
30        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
31
32        let content = self.get_file_content(file_path)?;
33        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
34        debug!("Line content: {}", line_content);
35
36        let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
37        debug!("Word at cursor: {:?}", word_at_cursor);
38
39        // Check if we're inside a fixture definition with the same name (self-referencing)
40        let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
41
42        // First, check if this word matches any fixture usage on this line
43        if let Some(usages) = self.usages.get(file_path) {
44            for usage in usages.iter() {
45                if usage.line == target_line && usage.name == word_at_cursor {
46                    let cursor_pos = character as usize;
47                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
48                        debug!(
49                            "Cursor at {} is within usage range {}-{}: {}",
50                            cursor_pos, usage.start_char, usage.end_char, usage.name
51                        );
52                        info!("Found fixture usage at cursor position: {}", usage.name);
53
54                        // If we're in a fixture definition with the same name, skip it
55                        if let Some(ref current_def) = current_fixture_def {
56                            if current_def.name == word_at_cursor {
57                                info!(
58                                    "Self-referencing fixture detected, finding parent definition"
59                                );
60                                return self.find_closest_definition_excluding(
61                                    file_path,
62                                    &usage.name,
63                                    Some(current_def),
64                                );
65                            }
66                        }
67
68                        return self.find_closest_definition(file_path, &usage.name);
69                    }
70                }
71            }
72        }
73
74        debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
75        None
76    }
77
78    /// Get the fixture definition at a specific line (if the line is a fixture definition)
79    fn get_fixture_definition_at_line(
80        &self,
81        file_path: &Path,
82        line: usize,
83    ) -> Option<FixtureDefinition> {
84        for entry in self.definitions.iter() {
85            for def in entry.value().iter() {
86                if def.file_path == file_path && def.line == line {
87                    return Some(def.clone());
88                }
89            }
90        }
91        None
92    }
93
94    /// Find fixture definition at a given position, checking both usages and definitions.
95    ///
96    /// This is useful for Call Hierarchy where we want to work on both fixture definition
97    /// lines and fixture usage sites.
98    pub fn find_fixture_or_definition_at_position(
99        &self,
100        file_path: &Path,
101        line: u32,
102        character: u32,
103    ) -> Option<FixtureDefinition> {
104        // First try to find a usage and resolve it to definition
105        if let Some(def) = self.find_fixture_definition(file_path, line, character) {
106            return Some(def);
107        }
108
109        // If not a usage, check if we're on a fixture definition line
110        let target_line = (line + 1) as usize; // Convert from 0-based to 1-based
111        let content = self.get_file_content(file_path)?;
112        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
113        let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
114
115        // Check if this word matches a fixture definition at this line
116        if let Some(definitions) = self.definitions.get(&word_at_cursor) {
117            for def in definitions.iter() {
118                if def.file_path == file_path && def.line == target_line {
119                    // Verify cursor is within the fixture name
120                    if character as usize >= def.start_char && (character as usize) < def.end_char {
121                        return Some(def.clone());
122                    }
123                }
124            }
125        }
126
127        None
128    }
129
130    /// Public method to get the fixture definition at a specific line and name
131    pub fn get_definition_at_line(
132        &self,
133        file_path: &Path,
134        line: usize,
135        fixture_name: &str,
136    ) -> Option<FixtureDefinition> {
137        if let Some(definitions) = self.definitions.get(fixture_name) {
138            for def in definitions.iter() {
139                if def.file_path == file_path && def.line == line {
140                    return Some(def.clone());
141                }
142            }
143        }
144        None
145    }
146
147    /// Find the closest fixture definition based on pytest priority rules.
148    pub(crate) fn find_closest_definition(
149        &self,
150        file_path: &Path,
151        fixture_name: &str,
152    ) -> Option<FixtureDefinition> {
153        self.find_closest_definition_with_filter(file_path, fixture_name, |_| true)
154    }
155
156    /// Find the closest definition, excluding a specific definition.
157    pub(crate) fn find_closest_definition_excluding(
158        &self,
159        file_path: &Path,
160        fixture_name: &str,
161        exclude: Option<&FixtureDefinition>,
162    ) -> Option<FixtureDefinition> {
163        self.find_closest_definition_with_filter(file_path, fixture_name, |def| {
164            if let Some(excluded) = exclude {
165                def != excluded
166            } else {
167                true
168            }
169        })
170    }
171
172    /// Internal helper that implements pytest priority rules with a custom filter.
173    /// Priority order:
174    /// 1. Same file (highest priority, last definition wins)
175    /// 2. Closest conftest.py in parent directories (including imported fixtures)
176    /// 3. Third-party fixtures from site-packages
177    fn find_closest_definition_with_filter<F>(
178        &self,
179        file_path: &Path,
180        fixture_name: &str,
181        filter: F,
182    ) -> Option<FixtureDefinition>
183    where
184        F: Fn(&FixtureDefinition) -> bool,
185    {
186        let definitions = self.definitions.get(fixture_name)?;
187
188        // Priority 1: Same file (highest priority)
189        debug!(
190            "Checking for fixture {} in same file: {:?}",
191            fixture_name, file_path
192        );
193
194        if let Some(last_def) = definitions
195            .iter()
196            .filter(|def| def.file_path == file_path && filter(def))
197            .max_by_key(|def| def.line)
198        {
199            info!(
200                "Found fixture {} in same file at line {}",
201                fixture_name, last_def.line
202            );
203            return Some(last_def.clone());
204        }
205
206        // Priority 2: Search upward through conftest.py files
207        let mut current_dir = file_path.parent()?;
208
209        debug!(
210            "Searching for fixture {} in conftest.py files starting from {:?}",
211            fixture_name, current_dir
212        );
213        loop {
214            let conftest_path = current_dir.join("conftest.py");
215            debug!("  Checking conftest.py at: {:?}", conftest_path);
216
217            // First check if the fixture is defined directly in this conftest
218            for def in definitions.iter() {
219                if def.file_path == conftest_path && filter(def) {
220                    info!(
221                        "Found fixture {} in conftest.py: {:?}",
222                        fixture_name, conftest_path
223                    );
224                    return Some(def.clone());
225                }
226            }
227
228            // Then check if the conftest imports this fixture
229            // Check both filesystem and file cache for conftest existence
230            let conftest_in_cache = self.file_cache.contains_key(&conftest_path);
231            if (conftest_path.exists() || conftest_in_cache)
232                && self.is_fixture_imported_in_file(fixture_name, &conftest_path)
233            {
234                // The fixture is imported in this conftest, so it's available here
235                // Return the original definition (pytest makes it available at conftest scope)
236                debug!(
237                    "Fixture {} is imported in conftest.py: {:?}",
238                    fixture_name, conftest_path
239                );
240                // Get any matching definition that passes the filter
241                if let Some(def) = definitions.iter().find(|def| filter(def)) {
242                    info!(
243                        "Found imported fixture {} via conftest.py: {:?} (original: {:?})",
244                        fixture_name, conftest_path, def.file_path
245                    );
246                    return Some(def.clone());
247                }
248            }
249
250            match current_dir.parent() {
251                Some(parent) => current_dir = parent,
252                None => break,
253            }
254        }
255
256        // Priority 3: Plugin fixtures (discovered via pytest11 entry points)
257        // These are globally available like third-party fixtures, but from workspace-local
258        // editable installs that aren't in site-packages or conftest.py.
259        debug!(
260            "No fixture {} found in conftest hierarchy, checking plugins",
261            fixture_name
262        );
263        for def in definitions.iter() {
264            if def.is_plugin && !def.is_third_party && filter(def) {
265                info!(
266                    "Found plugin fixture {} via pytest11 entry point: {:?}",
267                    fixture_name, def.file_path
268                );
269                return Some(def.clone());
270            }
271        }
272
273        // Priority 4: Third-party fixtures (site-packages)
274        debug!(
275            "No fixture {} found in plugins, checking third-party",
276            fixture_name
277        );
278        for def in definitions.iter() {
279            if def.is_third_party && filter(def) {
280                info!(
281                    "Found third-party fixture {} in site-packages: {:?}",
282                    fixture_name, def.file_path
283                );
284                return Some(def.clone());
285            }
286        }
287
288        debug!(
289            "No fixture {} found in scope for {:?}",
290            fixture_name, file_path
291        );
292        None
293    }
294
295    /// Find the fixture name at a given position (either definition or usage)
296    pub fn find_fixture_at_position(
297        &self,
298        file_path: &Path,
299        line: u32,
300        character: u32,
301    ) -> Option<String> {
302        let target_line = (line + 1) as usize;
303
304        debug!(
305            "find_fixture_at_position: file={:?}, line={}, char={}",
306            file_path, target_line, character
307        );
308
309        let content = self.get_file_content(file_path)?;
310        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
311        debug!("Line content: {}", line_content);
312
313        let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
314        debug!("Word at cursor: {:?}", word_at_cursor);
315
316        // Check if this word matches any fixture usage on this line
317        if let Some(usages) = self.usages.get(file_path) {
318            for usage in usages.iter() {
319                if usage.line == target_line {
320                    let cursor_pos = character as usize;
321                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
322                        debug!(
323                            "Cursor at {} is within usage range {}-{}: {}",
324                            cursor_pos, usage.start_char, usage.end_char, usage.name
325                        );
326                        info!("Found fixture usage at cursor position: {}", usage.name);
327                        return Some(usage.name.clone());
328                    }
329                }
330            }
331        }
332
333        // Check if we're on a fixture definition line
334        for entry in self.definitions.iter() {
335            for def in entry.value().iter() {
336                if def.file_path == file_path && def.line == target_line {
337                    if let Some(ref word) = word_at_cursor {
338                        if word == &def.name {
339                            info!(
340                                "Found fixture definition name at cursor position: {}",
341                                def.name
342                            );
343                            return Some(def.name.clone());
344                        }
345                    }
346                }
347            }
348        }
349
350        debug!("No fixture found at cursor position");
351        None
352    }
353
354    /// Extract the word at a given character position in a line
355    pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
356        super::string_utils::extract_word_at_position(line, character)
357    }
358
359    /// Find all references (usages) of a fixture by name
360    pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
361        info!("Finding all references for fixture: {}", fixture_name);
362
363        let mut all_references = Vec::new();
364
365        for entry in self.usages.iter() {
366            let file_path = entry.key();
367            let usages = entry.value();
368
369            for usage in usages.iter() {
370                if usage.name == fixture_name {
371                    debug!(
372                        "Found reference to {} in {:?} at line {}",
373                        fixture_name, file_path, usage.line
374                    );
375                    all_references.push(usage.clone());
376                }
377            }
378        }
379
380        info!(
381            "Found {} total references for fixture: {}",
382            all_references.len(),
383            fixture_name
384        );
385        all_references
386    }
387
388    /// Find all references that resolve to a specific fixture definition.
389    /// Uses the usage_by_fixture reverse index for O(m) lookup where m = usages of this fixture,
390    /// instead of O(n) iteration over all usages.
391    pub fn find_references_for_definition(
392        &self,
393        definition: &FixtureDefinition,
394    ) -> Vec<FixtureUsage> {
395        info!(
396            "Finding references for specific definition: {} at {:?}:{}",
397            definition.name, definition.file_path, definition.line
398        );
399
400        let mut matching_references = Vec::new();
401
402        // Use reverse index for O(m) lookup instead of O(n) iteration over all usages
403        let Some(usages_for_fixture) = self.usage_by_fixture.get(&definition.name) else {
404            info!("No references found for fixture: {}", definition.name);
405            return matching_references;
406        };
407
408        for (file_path, usage) in usages_for_fixture.iter() {
409            let fixture_def_at_line = self.get_fixture_definition_at_line(file_path, usage.line);
410
411            let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
412                if current_def.name == usage.name {
413                    debug!(
414                        "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
415                        file_path, usage.line, current_def.line
416                    );
417                    self.find_closest_definition_excluding(
418                        file_path,
419                        &usage.name,
420                        Some(current_def),
421                    )
422                } else {
423                    self.find_closest_definition(file_path, &usage.name)
424                }
425            } else {
426                self.find_closest_definition(file_path, &usage.name)
427            };
428
429            if let Some(resolved_def) = resolved_def {
430                if resolved_def == *definition {
431                    debug!(
432                        "Usage at {:?}:{} resolves to our definition",
433                        file_path, usage.line
434                    );
435                    matching_references.push(usage.clone());
436                } else {
437                    debug!(
438                        "Usage at {:?}:{} resolves to different definition at {:?}:{}",
439                        file_path, usage.line, resolved_def.file_path, resolved_def.line
440                    );
441                }
442            }
443        }
444
445        info!(
446            "Found {} references that resolve to this specific definition",
447            matching_references.len()
448        );
449        matching_references
450    }
451
452    /// Get all undeclared fixture usages for a file
453    pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
454        self.undeclared_fixtures
455            .get(file_path)
456            .map(|entry| entry.value().clone())
457            .unwrap_or_default()
458    }
459
460    /// Get all available fixtures for a given file.
461    /// Results are cached with version-based invalidation for performance.
462    /// Returns Arc to avoid cloning the potentially large Vec on cache hits.
463    pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
464        use std::sync::Arc;
465
466        // Canonicalize path for consistent cache keys
467        let file_path = self.get_canonical_path(file_path.to_path_buf());
468
469        // Check cache first
470        let current_version = self
471            .definitions_version
472            .load(std::sync::atomic::Ordering::SeqCst);
473
474        if let Some(cached) = self.available_fixtures_cache.get(&file_path) {
475            let (cached_version, cached_fixtures) = cached.value();
476            if *cached_version == current_version {
477                // Return cloned Vec from Arc (cheap reference count increment)
478                return cached_fixtures.as_ref().clone();
479            }
480        }
481
482        // Compute available fixtures
483        let available_fixtures = self.compute_available_fixtures(&file_path);
484
485        // Store in cache
486        self.available_fixtures_cache.insert(
487            file_path,
488            (current_version, Arc::new(available_fixtures.clone())),
489        );
490
491        available_fixtures
492    }
493
494    /// Internal method to compute available fixtures without caching.
495    fn compute_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
496        let mut available_fixtures = Vec::new();
497        let mut seen_names = HashSet::new();
498
499        // Priority 1: Fixtures in the same file
500        for entry in self.definitions.iter() {
501            let fixture_name = entry.key();
502            for def in entry.value().iter() {
503                if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
504                    available_fixtures.push(def.clone());
505                    seen_names.insert(fixture_name.clone());
506                }
507            }
508        }
509
510        // Priority 2: Fixtures in conftest.py files (including imported fixtures)
511        if let Some(mut current_dir) = file_path.parent() {
512            loop {
513                let conftest_path = current_dir.join("conftest.py");
514
515                // First add fixtures defined directly in the conftest
516                for entry in self.definitions.iter() {
517                    let fixture_name = entry.key();
518                    for def in entry.value().iter() {
519                        if def.file_path == conftest_path
520                            && !seen_names.contains(fixture_name.as_str())
521                        {
522                            available_fixtures.push(def.clone());
523                            seen_names.insert(fixture_name.clone());
524                        }
525                    }
526                }
527
528                // Then add fixtures imported into the conftest
529                if self.file_cache.contains_key(&conftest_path) {
530                    let mut visited = HashSet::new();
531                    let imported_fixtures =
532                        self.get_imported_fixtures(&conftest_path, &mut visited);
533                    for fixture_name in imported_fixtures {
534                        if !seen_names.contains(&fixture_name) {
535                            // Get the original definition for this imported fixture
536                            if let Some(definitions) = self.definitions.get(&fixture_name) {
537                                if let Some(def) = definitions.first() {
538                                    available_fixtures.push(def.clone());
539                                    seen_names.insert(fixture_name);
540                                }
541                            }
542                        }
543                    }
544                }
545
546                match current_dir.parent() {
547                    Some(parent) => current_dir = parent,
548                    None => break,
549                }
550            }
551        }
552
553        // Priority 3: Plugin fixtures (pytest11 entry points, e.g. workspace editable installs)
554        for entry in self.definitions.iter() {
555            let fixture_name = entry.key();
556            for def in entry.value().iter() {
557                if def.is_plugin
558                    && !def.is_third_party
559                    && !seen_names.contains(fixture_name.as_str())
560                {
561                    available_fixtures.push(def.clone());
562                    seen_names.insert(fixture_name.clone());
563                }
564            }
565        }
566
567        // Priority 4: Third-party fixtures from site-packages
568        for entry in self.definitions.iter() {
569            let fixture_name = entry.key();
570            for def in entry.value().iter() {
571                if def.is_third_party && !seen_names.contains(fixture_name.as_str()) {
572                    available_fixtures.push(def.clone());
573                    seen_names.insert(fixture_name.clone());
574                }
575            }
576        }
577
578        available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
579        available_fixtures
580    }
581
582    /// Get the completion context for a given position
583    pub fn get_completion_context(
584        &self,
585        file_path: &Path,
586        line: u32,
587        character: u32,
588    ) -> Option<CompletionContext> {
589        let content = self.get_file_content(file_path)?;
590        let target_line = (line + 1) as usize;
591
592        // Try AST-based analysis first
593        let parsed = self.get_parsed_ast(file_path, &content);
594
595        if let Some(parsed) = parsed {
596            let line_index = self.get_line_index(file_path, &content);
597
598            if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
599                // First check if we're inside a decorator
600                if let Some(ctx) =
601                    self.check_decorator_context(&module.body, &content, target_line, &line_index)
602                {
603                    return Some(ctx);
604                }
605
606                // Then check for function context
607                if let Some(ctx) = self.get_function_completion_context(
608                    &module.body,
609                    &content,
610                    target_line,
611                    character as usize,
612                    &line_index,
613                ) {
614                    return Some(ctx);
615                }
616            }
617        }
618
619        // Fallback: text-based analysis for incomplete/invalid Python
620        self.get_completion_context_from_text(&content, target_line)
621    }
622
623    /// Check whether a `@pytest.fixture` decorator appears in the lines immediately
624    /// above `def_line_idx` (0-based index into `lines`).
625    ///
626    /// Scans upward through decorator lines (lines starting with `@` after stripping
627    /// whitespace) and blank lines, stopping at the first non-decorator, non-blank line.
628    fn has_fixture_decorator_above(lines: &[&str], def_line_idx: usize) -> bool {
629        if def_line_idx == 0 {
630            return false;
631        }
632        let mut i = def_line_idx - 1;
633        loop {
634            let trimmed = lines[i].trim();
635            if trimmed.is_empty() {
636                // Skip blank lines between decorators and def
637                if i == 0 {
638                    break;
639                }
640                i -= 1;
641                continue;
642            }
643            if trimmed.starts_with('@') {
644                // Check for @pytest.fixture or @fixture (with optional parens/args)
645                if trimmed.contains("pytest.fixture") || trimmed.starts_with("@fixture") {
646                    return true;
647                }
648                // Another decorator — keep scanning upward
649                if i == 0 {
650                    break;
651                }
652                i -= 1;
653                continue;
654            }
655            // Hit a non-decorator, non-blank line — stop
656            break;
657        }
658        false
659    }
660
661    /// Extract the fixture scope from decorator text above a function definition.
662    ///
663    /// Scans decorator lines above `def_line_idx` for `@pytest.fixture(scope="...")`.
664    /// Searches each decorator line individually to avoid quadratic string building.
665    /// Returns `None` if no scope keyword is found (caller should default to `Function`).
666    fn extract_fixture_scope_from_text(
667        lines: &[&str],
668        def_line_idx: usize,
669    ) -> Option<FixtureScope> {
670        if def_line_idx == 0 {
671            return None;
672        }
673
674        // Scan decorator lines above the def and search each one for scope=
675        let mut i = def_line_idx - 1;
676        loop {
677            let trimmed = lines[i].trim();
678            if trimmed.is_empty() {
679                if i == 0 {
680                    break;
681                }
682                i -= 1;
683                continue;
684            }
685            if trimmed.starts_with('@') {
686                // Check this decorator line for scope="..." or scope='...'
687                for pattern in &["scope=\"", "scope='"] {
688                    if let Some(pos) = trimmed.find(pattern) {
689                        let start = pos + pattern.len();
690                        let quote_char = if pattern.ends_with('"') { '"' } else { '\'' };
691                        if let Some(end) = trimmed[start..].find(quote_char) {
692                            let scope_str = &trimmed[start..start + end];
693                            return FixtureScope::parse(scope_str);
694                        }
695                    }
696                }
697                if i == 0 {
698                    break;
699                }
700                i -= 1;
701                continue;
702            }
703            break;
704        }
705
706        None
707    }
708
709    /// Text-based fallback for detecting usefixtures decorator and pytestmark contexts.
710    ///
711    /// Handles cases like:
712    ///   `@pytest.mark.usefixtures(`
713    ///   `pytestmark = [pytest.mark.usefixtures(`
714    ///
715    /// Returns `Some(UsefixturesDecorator)` if the cursor appears to be inside
716    /// an unclosed `pytest.mark.usefixtures(` call.
717    fn get_usefixtures_context_from_text(
718        lines: &[&str],
719        cursor_idx: usize,
720    ) -> Option<CompletionContext> {
721        // Scan backward from cursor line looking for usefixtures pattern
722        let scan_limit = cursor_idx.saturating_sub(10);
723
724        let mut i = cursor_idx;
725        loop {
726            let line = lines[i];
727            if let Some(pos) = line.find("usefixtures(") {
728                // Found the pattern — check if cursor is inside the unclosed call
729                // Count parens from the usefixtures( position to the cursor
730                let mut depth: i32 = 0;
731
732                // Count from the opening paren on this line
733                for ch in line[pos..].chars() {
734                    if ch == '(' {
735                        depth += 1;
736                    }
737                    if ch == ')' {
738                        depth -= 1;
739                    }
740                }
741
742                // Continue counting on subsequent lines up to cursor.
743                // Skip when i == cursor_idx since (i + 1)..=cursor_idx would panic.
744                if i < cursor_idx {
745                    for line in &lines[(i + 1)..=cursor_idx] {
746                        for ch in line.chars() {
747                            if ch == '(' {
748                                depth += 1;
749                            }
750                            if ch == ')' {
751                                depth -= 1;
752                            }
753                        }
754                    }
755                }
756
757                // If depth > 0, we're inside the unclosed usefixtures call
758                if depth > 0 {
759                    return Some(CompletionContext::UsefixturesDecorator);
760                }
761
762                // depth == 0 and cursor is on the same line as the opening —
763                // Only offer completions if cursor is positioned between the parens,
764                // not after a fully closed usefixtures() call.
765                if i == cursor_idx && depth == 0 {
766                    // Find the closing paren position; if cursor is before it,
767                    // we're still inside the call.
768                    if let Some(close_pos) = line[pos..].rfind(')') {
769                        let abs_close = pos + close_pos;
770                        // Cursor column is approximated by line length at this point;
771                        // but since we don't have the cursor column here, we check
772                        // whether the opening and closing paren are adjacent (empty call)
773                        // — in that case the user likely wants completions inside "()".
774                        let open_pos = pos + line[pos..].find('(').unwrap_or(0);
775                        if abs_close == open_pos + 1 {
776                            // Empty parens like usefixtures() — offer completions
777                            return Some(CompletionContext::UsefixturesDecorator);
778                        }
779                        // Parens are balanced with content — user may be done
780                        return None;
781                    }
782                    // No closing paren found on this line — unclosed call
783                    return Some(CompletionContext::UsefixturesDecorator);
784                }
785            }
786
787            if i == 0 || i <= scan_limit {
788                break;
789            }
790            i -= 1;
791        }
792
793        None
794    }
795
796    /// Text-based fallback for completion context when the AST parser fails.
797    ///
798    /// Checks for two kinds of contexts:
799    /// 1. Usefixtures/pytestmark decorator contexts (checked first, like the AST path)
800    /// 2. Function signature contexts (def/async def lines)
801    fn get_completion_context_from_text(
802        &self,
803        content: &str,
804        target_line: usize,
805    ) -> Option<CompletionContext> {
806        let mut lines: Vec<&str> = content.lines().collect();
807
808        // Feature #2: handle trailing newline — str::lines() omits the trailing
809        // empty line when content ends with '\n'
810        if content.ends_with('\n') {
811            lines.push("");
812        }
813
814        if target_line == 0 || target_line > lines.len() {
815            return None;
816        }
817
818        let cursor_idx = target_line - 1; // 0-based
819
820        // Check usefixtures/pytestmark context first (mirrors AST path priority)
821        if let Some(ctx) = Self::get_usefixtures_context_from_text(&lines, cursor_idx) {
822            return Some(ctx);
823        }
824
825        // Scan backward for def/async def.
826        // Known limitation: only scans up to 50 lines backward. If the cursor is
827        // deep inside a very long incomplete function body (>50 lines), the text
828        // fallback won't find the enclosing `def`. This is acceptable because the
829        // AST-based path handles complete functions of any length; this fallback
830        // only runs for syntactically invalid (incomplete) Python.
831        let mut def_line_idx = None;
832        let scan_limit = cursor_idx.saturating_sub(50);
833
834        let mut i = cursor_idx;
835        loop {
836            let trimmed = lines[i].trim();
837            if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
838                def_line_idx = Some(i);
839                break;
840            }
841            if i == 0 || i <= scan_limit {
842                break;
843            }
844            i -= 1;
845        }
846
847        let def_line_idx = def_line_idx?;
848        let def_line = lines[def_line_idx].trim();
849
850        // Extract function name
851        let name_start = if def_line.starts_with("async def ") {
852            "async def ".len()
853        } else {
854            "def ".len()
855        };
856        let remaining = &def_line[name_start..];
857        let func_name: String = remaining
858            .chars()
859            .take_while(|c| c.is_alphanumeric() || *c == '_')
860            .collect();
861
862        if func_name.is_empty() {
863            return None;
864        }
865
866        // Determine is_test / is_fixture
867        let is_test = func_name.starts_with("test_");
868        let is_fixture = Self::has_fixture_decorator_above(&lines, def_line_idx);
869
870        // No completions for regular functions
871        if !is_test && !is_fixture {
872            return None;
873        }
874
875        // Check parenthesis state from def line to cursor, tracking whether
876        // the cursor is inside the parentheses (between '(' and ')').
877        // We need to determine if the cursor is within the signature parens,
878        // not just whether both parens exist in the scanned range.
879        let mut paren_depth: i32 = 0;
880        let mut cursor_inside_parens = false;
881        let mut found_open = false;
882        let mut signature_closed = false; // True when ')' found AND cursor is after it
883
884        for (line_idx_offset, line) in lines[def_line_idx..=cursor_idx].iter().enumerate() {
885            let current_line_idx = def_line_idx + line_idx_offset;
886            let is_cursor_line = current_line_idx == cursor_idx;
887
888            for ch in line.chars() {
889                if ch == '(' {
890                    paren_depth += 1;
891                    if paren_depth == 1 {
892                        found_open = true;
893                    }
894                } else if ch == ')' {
895                    paren_depth -= 1;
896                    if paren_depth == 0 && found_open {
897                        // Closing paren of the signature found
898                        if !is_cursor_line {
899                            // Cursor is on a line after the closing paren
900                            signature_closed = true;
901                        }
902                        // If on cursor line, cursor might be before or after ')'
903                        // but since this is a text fallback for broken syntax,
904                        // we treat cursor on the same line as still in-signature
905                    }
906                }
907            }
908
909            // After processing the cursor line, check if we're inside parens
910            if is_cursor_line && found_open && paren_depth > 0 {
911                cursor_inside_parens = true;
912            }
913        }
914
915        // Reject only if the signature is fully closed AND the cursor is on a
916        // subsequent line (i.e. in the body area). Since this fallback only runs
917        // when the AST parse failed (incomplete/invalid Python), having both
918        // parens on the def line does NOT mean the function is complete — there
919        // may be no body yet (e.g. "def test_bla():").
920        //
921        // When the cursor is on the same line as the def (between or after the
922        // parens), we still offer completions because the user is likely still
923        // editing the signature of an incomplete function.
924        if signature_closed && !cursor_inside_parens {
925            return None;
926        }
927
928        // If both parens are present on the def line but cursor is on that same
929        // line or inside parens, we still want to provide completions.
930        // Also handle the case where the cursor is on the def line after '):'
931        // — this is still useful since the function has no body.
932
933        // Extract existing parameters
934        let mut declared_params = Vec::new();
935        if found_open {
936            let mut param_text = String::new();
937            let mut past_open = false;
938            let mut past_close = false;
939            for line in &lines[def_line_idx..=cursor_idx] {
940                for ch in line.chars() {
941                    if past_close {
942                        // Stop collecting after closing paren
943                        continue;
944                    } else if past_open {
945                        if ch == ')' {
946                            past_close = true;
947                        } else {
948                            param_text.push(ch);
949                        }
950                    } else if ch == '(' {
951                        past_open = true;
952                    }
953                }
954                if past_open && !past_close {
955                    param_text.push(' ');
956                }
957            }
958            for param in param_text.split(',') {
959                let name: String = param
960                    .trim()
961                    .chars()
962                    .take_while(|c| c.is_alphanumeric() || *c == '_')
963                    .collect();
964                if !name.is_empty() {
965                    declared_params.push(name);
966                }
967            }
968        }
969
970        // Determine fixture scope
971        let fixture_scope = if is_fixture {
972            let scope = Self::extract_fixture_scope_from_text(&lines, def_line_idx)
973                .unwrap_or(FixtureScope::Function);
974            Some(scope)
975        } else {
976            None
977        };
978
979        Some(CompletionContext::FunctionSignature {
980            function_name: func_name,
981            function_line: def_line_idx + 1, // 1-based
982            is_fixture,
983            declared_params,
984            fixture_scope,
985        })
986    }
987
988    /// Check if the cursor is inside a decorator that needs fixture completions,
989    /// or inside a pytestmark assignment's usefixtures call.
990    fn check_decorator_context(
991        &self,
992        stmts: &[Stmt],
993        _content: &str,
994        target_line: usize,
995        line_index: &[usize],
996    ) -> Option<CompletionContext> {
997        for stmt in stmts {
998            // Check decorators on functions and classes
999            let decorator_list = match stmt {
1000                Stmt::FunctionDef(f) => Some(f.decorator_list.as_slice()),
1001                Stmt::AsyncFunctionDef(f) => Some(f.decorator_list.as_slice()),
1002                Stmt::ClassDef(c) => Some(c.decorator_list.as_slice()),
1003                _ => None,
1004            };
1005
1006            if let Some(decorator_list) = decorator_list {
1007                for decorator in decorator_list {
1008                    let dec_start_line =
1009                        self.get_line_from_offset(decorator.range().start().to_usize(), line_index);
1010                    let dec_end_line =
1011                        self.get_line_from_offset(decorator.range().end().to_usize(), line_index);
1012
1013                    if target_line >= dec_start_line && target_line <= dec_end_line {
1014                        if decorators::is_usefixtures_decorator(decorator) {
1015                            return Some(CompletionContext::UsefixturesDecorator);
1016                        }
1017                        if decorators::is_parametrize_decorator(decorator) {
1018                            return Some(CompletionContext::ParametrizeIndirect);
1019                        }
1020                    }
1021                }
1022            }
1023
1024            // Check pytestmark = ... and pytestmark: T = ... assignments
1025            let pytestmark_value: Option<&Expr> = match stmt {
1026                Stmt::Assign(assign) => {
1027                    let is_pytestmark = assign
1028                        .targets
1029                        .iter()
1030                        .any(|t| matches!(t, Expr::Name(n) if n.id.as_str() == "pytestmark"));
1031                    if is_pytestmark {
1032                        Some(assign.value.as_ref())
1033                    } else {
1034                        None
1035                    }
1036                }
1037                Stmt::AnnAssign(ann_assign) => {
1038                    let is_pytestmark = matches!(
1039                        ann_assign.target.as_ref(),
1040                        Expr::Name(n) if n.id.as_str() == "pytestmark"
1041                    );
1042                    if is_pytestmark {
1043                        ann_assign.value.as_ref().map(|v| v.as_ref())
1044                    } else {
1045                        None
1046                    }
1047                }
1048                _ => None,
1049            };
1050
1051            if let Some(value) = pytestmark_value {
1052                let stmt_start =
1053                    self.get_line_from_offset(stmt.range().start().to_usize(), line_index);
1054                let stmt_end = self.get_line_from_offset(stmt.range().end().to_usize(), line_index);
1055
1056                if target_line >= stmt_start
1057                    && target_line <= stmt_end
1058                    && self.cursor_inside_usefixtures_call(value, target_line, line_index)
1059                {
1060                    return Some(CompletionContext::UsefixturesDecorator);
1061                }
1062            }
1063
1064            // Recursively check class bodies
1065            if let Stmt::ClassDef(class_def) = stmt {
1066                if let Some(ctx) =
1067                    self.check_decorator_context(&class_def.body, _content, target_line, line_index)
1068                {
1069                    return Some(ctx);
1070                }
1071            }
1072        }
1073
1074        None
1075    }
1076
1077    /// Returns true if `target_line` falls within any `pytest.mark.usefixtures(...)` call
1078    /// anywhere inside `expr` (including nested in lists/tuples).
1079    fn cursor_inside_usefixtures_call(
1080        &self,
1081        expr: &Expr,
1082        target_line: usize,
1083        line_index: &[usize],
1084    ) -> bool {
1085        match expr {
1086            Expr::Call(call) => {
1087                if decorators::is_usefixtures_decorator(&call.func) {
1088                    let call_start =
1089                        self.get_line_from_offset(expr.range().start().to_usize(), line_index);
1090                    let call_end =
1091                        self.get_line_from_offset(expr.range().end().to_usize(), line_index);
1092                    return target_line >= call_start && target_line <= call_end;
1093                }
1094                false
1095            }
1096            Expr::List(list) => list
1097                .elts
1098                .iter()
1099                .any(|e| self.cursor_inside_usefixtures_call(e, target_line, line_index)),
1100            Expr::Tuple(tuple) => tuple
1101                .elts
1102                .iter()
1103                .any(|e| self.cursor_inside_usefixtures_call(e, target_line, line_index)),
1104            _ => false,
1105        }
1106    }
1107
1108    /// Get completion context when cursor is inside a function
1109    fn get_function_completion_context(
1110        &self,
1111        stmts: &[Stmt],
1112        content: &str,
1113        target_line: usize,
1114        target_char: usize,
1115        line_index: &[usize],
1116    ) -> Option<CompletionContext> {
1117        for stmt in stmts {
1118            match stmt {
1119                Stmt::FunctionDef(func_def) => {
1120                    if let Some(ctx) = self.get_func_context(
1121                        &func_def.name,
1122                        &func_def.decorator_list,
1123                        &func_def.args,
1124                        &func_def.returns,
1125                        &func_def.body,
1126                        func_def.range,
1127                        content,
1128                        target_line,
1129                        target_char,
1130                        line_index,
1131                    ) {
1132                        return Some(ctx);
1133                    }
1134                }
1135                Stmt::AsyncFunctionDef(func_def) => {
1136                    if let Some(ctx) = self.get_func_context(
1137                        &func_def.name,
1138                        &func_def.decorator_list,
1139                        &func_def.args,
1140                        &func_def.returns,
1141                        &func_def.body,
1142                        func_def.range,
1143                        content,
1144                        target_line,
1145                        target_char,
1146                        line_index,
1147                    ) {
1148                        return Some(ctx);
1149                    }
1150                }
1151                Stmt::ClassDef(class_def) => {
1152                    if let Some(ctx) = self.get_function_completion_context(
1153                        &class_def.body,
1154                        content,
1155                        target_line,
1156                        target_char,
1157                        line_index,
1158                    ) {
1159                        return Some(ctx);
1160                    }
1161                }
1162                _ => {}
1163            }
1164        }
1165
1166        None
1167    }
1168
1169    /// Find the line where the function signature ends (the line containing the trailing `:`).
1170    ///
1171    /// Uses AST range information from arguments, return annotation, and body statements
1172    /// to locate the signature boundary. Falls back to scanning for `:` after the last
1173    /// known signature element.
1174    fn find_signature_end_line(
1175        &self,
1176        func_start_line: usize,
1177        args: &rustpython_parser::ast::Arguments,
1178        returns: &Option<Box<Expr>>,
1179        body: &[Stmt],
1180        content: &str,
1181        line_index: &[usize],
1182    ) -> usize {
1183        // Find the last AST element in the signature
1184        let mut last_sig_offset: Option<usize> = None;
1185
1186        // Check return annotation
1187        if let Some(ret) = returns {
1188            last_sig_offset = Some(ret.range().end().to_usize());
1189        }
1190
1191        // Check all argument categories for the one ending latest
1192        let all_arg_ends = args
1193            .args
1194            .iter()
1195            .chain(args.posonlyargs.iter())
1196            .chain(args.kwonlyargs.iter())
1197            .map(|a| a.def.range.end().to_usize())
1198            .chain(args.vararg.as_ref().map(|a| a.range.end().to_usize()))
1199            .chain(args.kwarg.as_ref().map(|a| a.range.end().to_usize()));
1200
1201        if let Some(max_arg_end) = all_arg_ends.max() {
1202            last_sig_offset =
1203                Some(last_sig_offset.map_or(max_arg_end, |prev| prev.max(max_arg_end)));
1204        }
1205
1206        // Convert to line number
1207        let last_sig_line = last_sig_offset
1208            .map(|offset| self.get_line_from_offset(offset, line_index))
1209            .unwrap_or(func_start_line);
1210
1211        // Upper bound: line before first body statement
1212        let first_body_line = body
1213            .first()
1214            .map(|stmt| self.get_line_from_offset(stmt.range().start().to_usize(), line_index));
1215
1216        // Scan forward from last_sig_line looking for trailing ":"
1217        let lines: Vec<&str> = content.lines().collect();
1218        let scan_end = first_body_line
1219            .unwrap_or(last_sig_line + 10)
1220            .min(last_sig_line + 10)
1221            .min(lines.len());
1222        let scan_start = last_sig_line.saturating_sub(1);
1223
1224        for (i, line) in lines
1225            .iter()
1226            .enumerate()
1227            .skip(scan_start)
1228            .take(scan_end.saturating_sub(scan_start))
1229        {
1230            let trimmed = line.trim();
1231            if trimmed.ends_with(':') {
1232                return i + 1; // Convert to 1-based
1233            }
1234        }
1235
1236        // Fallback: if body exists, signature ends on the line before the body
1237        if let Some(body_line) = first_body_line {
1238            return body_line.saturating_sub(1).max(func_start_line);
1239        }
1240
1241        // Last resort: function start line
1242        func_start_line
1243    }
1244
1245    /// Helper to get function completion context
1246    #[allow(clippy::too_many_arguments)]
1247    fn get_func_context(
1248        &self,
1249        func_name: &rustpython_parser::ast::Identifier,
1250        decorator_list: &[Expr],
1251        args: &rustpython_parser::ast::Arguments,
1252        returns: &Option<Box<Expr>>,
1253        body: &[Stmt],
1254        range: rustpython_parser::text_size::TextRange,
1255        content: &str,
1256        target_line: usize,
1257        _target_char: usize,
1258        line_index: &[usize],
1259    ) -> Option<CompletionContext> {
1260        let func_start_line = self.get_line_from_offset(range.start().to_usize(), line_index);
1261        let func_end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
1262
1263        if target_line < func_start_line || target_line > func_end_line {
1264            return None;
1265        }
1266
1267        let is_fixture = decorator_list.iter().any(decorators::is_fixture_decorator);
1268        let is_test = func_name.as_str().starts_with("test_");
1269
1270        if !is_test && !is_fixture {
1271            return None;
1272        }
1273
1274        // Determine fixture scope for scope-aware completion filtering
1275        let fixture_scope = if is_fixture {
1276            let scope = decorator_list
1277                .iter()
1278                .find_map(decorators::extract_fixture_scope)
1279                .unwrap_or(super::types::FixtureScope::Function);
1280            Some(scope)
1281        } else {
1282            None
1283        };
1284
1285        // Collect all parameters
1286        let params: Vec<String> = FixtureDatabase::all_args(args)
1287            .map(|arg| arg.def.arg.to_string())
1288            .collect();
1289
1290        // Find the line where the function signature ends using AST information
1291        let sig_end_line =
1292            self.find_signature_end_line(func_start_line, args, returns, body, content, line_index);
1293
1294        let in_signature = target_line <= sig_end_line;
1295
1296        let context = if in_signature {
1297            CompletionContext::FunctionSignature {
1298                function_name: func_name.to_string(),
1299                function_line: func_start_line,
1300                is_fixture,
1301                declared_params: params,
1302                fixture_scope,
1303            }
1304        } else {
1305            CompletionContext::FunctionBody {
1306                function_name: func_name.to_string(),
1307                function_line: func_start_line,
1308                is_fixture,
1309                declared_params: params,
1310                fixture_scope,
1311            }
1312        };
1313
1314        Some(context)
1315    }
1316
1317    /// Get information about where to insert a new parameter in a function signature
1318    pub fn get_function_param_insertion_info(
1319        &self,
1320        file_path: &Path,
1321        function_line: usize,
1322    ) -> Option<ParamInsertionInfo> {
1323        let content = self.get_file_content(file_path)?;
1324        let lines: Vec<&str> = content.lines().collect();
1325
1326        for i in (function_line.saturating_sub(1))..lines.len().min(function_line + 10) {
1327            let line = lines[i];
1328            if let Some(paren_pos) = line.find("):") {
1329                let has_params = if let Some(open_pos) = line.find('(') {
1330                    if open_pos < paren_pos {
1331                        let params_section = &line[open_pos + 1..paren_pos];
1332                        !params_section.trim().is_empty()
1333                    } else {
1334                        true
1335                    }
1336                } else {
1337                    let before_close = &line[..paren_pos];
1338                    if !before_close.trim().is_empty() {
1339                        true
1340                    } else {
1341                        let mut found_params = false;
1342                        for prev_line in lines.iter().take(i).skip(function_line.saturating_sub(1))
1343                        {
1344                            if prev_line.contains('(') {
1345                                if let Some(open_pos) = prev_line.find('(') {
1346                                    let after_open = &prev_line[open_pos + 1..];
1347                                    if !after_open.trim().is_empty() {
1348                                        found_params = true;
1349                                        break;
1350                                    }
1351                                }
1352                            } else if !prev_line.trim().is_empty() {
1353                                found_params = true;
1354                                break;
1355                            }
1356                        }
1357                        found_params
1358                    }
1359                };
1360
1361                return Some(ParamInsertionInfo {
1362                    line: i + 1,
1363                    char_pos: paren_pos,
1364                    needs_comma: has_params,
1365                });
1366            }
1367        }
1368
1369        None
1370    }
1371
1372    /// Check if a position is inside a test or fixture function (parameter or body)
1373    /// Returns Some((function_name, is_fixture, declared_params)) if inside a function
1374    #[allow(dead_code)] // Used in tests
1375    #[allow(dead_code)] // Used in tests
1376    pub fn is_inside_function(
1377        &self,
1378        file_path: &Path,
1379        line: u32,
1380        character: u32,
1381    ) -> Option<(String, bool, Vec<String>)> {
1382        // Try cache first, then file system
1383        let content = self.get_file_content(file_path)?;
1384
1385        let target_line = (line + 1) as usize; // Convert to 1-based
1386
1387        // Parse the file (using cached AST)
1388        let parsed = self.get_parsed_ast(file_path, &content)?;
1389
1390        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
1391            return self.find_enclosing_function(
1392                &module.body,
1393                &content,
1394                target_line,
1395                character as usize,
1396            );
1397        }
1398
1399        None
1400    }
1401
1402    #[allow(dead_code)]
1403    fn find_enclosing_function(
1404        &self,
1405        stmts: &[Stmt],
1406        content: &str,
1407        target_line: usize,
1408        _target_char: usize,
1409    ) -> Option<(String, bool, Vec<String>)> {
1410        let line_index = Self::build_line_index(content);
1411
1412        for stmt in stmts {
1413            match stmt {
1414                Stmt::FunctionDef(func_def) => {
1415                    let func_start_line =
1416                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
1417                    let func_end_line =
1418                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
1419
1420                    // Check if target is within this function's range
1421                    if target_line >= func_start_line && target_line <= func_end_line {
1422                        let is_fixture = func_def
1423                            .decorator_list
1424                            .iter()
1425                            .any(decorators::is_fixture_decorator);
1426                        let is_test = func_def.name.starts_with("test_");
1427
1428                        // Only return if it's a test or fixture
1429                        if is_test || is_fixture {
1430                            let params: Vec<String> = func_def
1431                                .args
1432                                .args
1433                                .iter()
1434                                .map(|arg| arg.def.arg.to_string())
1435                                .collect();
1436
1437                            return Some((func_def.name.to_string(), is_fixture, params));
1438                        }
1439                    }
1440                }
1441                Stmt::AsyncFunctionDef(func_def) => {
1442                    let func_start_line =
1443                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
1444                    let func_end_line =
1445                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
1446
1447                    if target_line >= func_start_line && target_line <= func_end_line {
1448                        let is_fixture = func_def
1449                            .decorator_list
1450                            .iter()
1451                            .any(decorators::is_fixture_decorator);
1452                        let is_test = func_def.name.starts_with("test_");
1453
1454                        if is_test || is_fixture {
1455                            let params: Vec<String> = func_def
1456                                .args
1457                                .args
1458                                .iter()
1459                                .map(|arg| arg.def.arg.to_string())
1460                                .collect();
1461
1462                            return Some((func_def.name.to_string(), is_fixture, params));
1463                        }
1464                    }
1465                }
1466                _ => {}
1467            }
1468        }
1469
1470        None
1471    }
1472
1473    // ============ Cycle Detection ============
1474
1475    /// Detect circular dependencies in fixtures with caching.
1476    /// Results are cached and only recomputed when definitions change.
1477    /// Returns Arc to avoid cloning the potentially large Vec.
1478    pub fn detect_fixture_cycles(&self) -> std::sync::Arc<Vec<super::types::FixtureCycle>> {
1479        use std::sync::Arc;
1480
1481        let current_version = self
1482            .definitions_version
1483            .load(std::sync::atomic::Ordering::SeqCst);
1484
1485        // Check cache first
1486        if let Some(cached) = self.cycle_cache.get(&()) {
1487            let (cached_version, cached_cycles) = cached.value();
1488            if *cached_version == current_version {
1489                return Arc::clone(cached_cycles);
1490            }
1491        }
1492
1493        // Compute cycles
1494        let cycles = Arc::new(self.compute_fixture_cycles());
1495
1496        // Store in cache
1497        self.cycle_cache
1498            .insert((), (current_version, Arc::clone(&cycles)));
1499
1500        cycles
1501    }
1502
1503    /// Actually compute fixture cycles using iterative DFS (Tarjan-like approach).
1504    /// Uses iterative algorithm to avoid stack overflow on deep dependency graphs.
1505    fn compute_fixture_cycles(&self) -> Vec<super::types::FixtureCycle> {
1506        use super::types::FixtureCycle;
1507        use std::collections::HashMap;
1508
1509        // Build dependency graph: fixture_name -> dependencies (only known fixtures)
1510        let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
1511        let mut fixture_defs: HashMap<String, FixtureDefinition> = HashMap::new();
1512
1513        for entry in self.definitions.iter() {
1514            let fixture_name = entry.key().clone();
1515            if let Some(def) = entry.value().first() {
1516                fixture_defs.insert(fixture_name.clone(), def.clone());
1517                // Only include dependencies that are known fixtures
1518                let valid_deps: Vec<String> = def
1519                    .dependencies
1520                    .iter()
1521                    .filter(|d| self.definitions.contains_key(*d))
1522                    .cloned()
1523                    .collect();
1524                dep_graph.insert(fixture_name, valid_deps);
1525            }
1526        }
1527
1528        let mut cycles = Vec::new();
1529        let mut visited: HashSet<String> = HashSet::new();
1530        let mut seen_cycles: HashSet<String> = HashSet::new(); // Deduplicate cycles
1531
1532        // Iterative DFS using explicit stack
1533        for start_fixture in dep_graph.keys() {
1534            if visited.contains(start_fixture) {
1535                continue;
1536            }
1537
1538            // Stack entries: (fixture_name, iterator_index, path_to_here)
1539            let mut stack: Vec<(String, usize, Vec<String>)> =
1540                vec![(start_fixture.clone(), 0, vec![])];
1541            let mut rec_stack: HashSet<String> = HashSet::new();
1542
1543            while let Some((current, idx, mut path)) = stack.pop() {
1544                if idx == 0 {
1545                    // First time visiting this node
1546                    if rec_stack.contains(&current) {
1547                        // Found a cycle
1548                        let cycle_start_idx = path.iter().position(|f| f == &current).unwrap_or(0);
1549                        let mut cycle_path: Vec<String> = path[cycle_start_idx..].to_vec();
1550                        cycle_path.push(current.clone());
1551
1552                        // Create a canonical key for deduplication (sorted cycle representation)
1553                        let mut cycle_key: Vec<String> =
1554                            cycle_path[..cycle_path.len() - 1].to_vec();
1555                        cycle_key.sort();
1556                        let cycle_key_str = cycle_key.join(",");
1557
1558                        if !seen_cycles.contains(&cycle_key_str) {
1559                            seen_cycles.insert(cycle_key_str);
1560                            if let Some(fixture_def) = fixture_defs.get(&current) {
1561                                cycles.push(FixtureCycle {
1562                                    cycle_path,
1563                                    fixture: fixture_def.clone(),
1564                                });
1565                            }
1566                        }
1567                        continue;
1568                    }
1569
1570                    rec_stack.insert(current.clone());
1571                    path.push(current.clone());
1572                }
1573
1574                // Get dependencies for current node
1575                let deps = match dep_graph.get(&current) {
1576                    Some(d) => d,
1577                    None => {
1578                        rec_stack.remove(&current);
1579                        continue;
1580                    }
1581                };
1582
1583                if idx < deps.len() {
1584                    // Push current back with next index
1585                    stack.push((current.clone(), idx + 1, path.clone()));
1586
1587                    let dep = &deps[idx];
1588                    if rec_stack.contains(dep) {
1589                        // Found a cycle through this dependency
1590                        let cycle_start_idx = path.iter().position(|f| f == dep).unwrap_or(0);
1591                        let mut cycle_path: Vec<String> = path[cycle_start_idx..].to_vec();
1592                        cycle_path.push(dep.clone());
1593
1594                        let mut cycle_key: Vec<String> =
1595                            cycle_path[..cycle_path.len() - 1].to_vec();
1596                        cycle_key.sort();
1597                        let cycle_key_str = cycle_key.join(",");
1598
1599                        if !seen_cycles.contains(&cycle_key_str) {
1600                            seen_cycles.insert(cycle_key_str);
1601                            if let Some(fixture_def) = fixture_defs.get(dep) {
1602                                cycles.push(FixtureCycle {
1603                                    cycle_path,
1604                                    fixture: fixture_def.clone(),
1605                                });
1606                            }
1607                        }
1608                    } else if !visited.contains(dep) {
1609                        // Explore this dependency
1610                        stack.push((dep.clone(), 0, path.clone()));
1611                    }
1612                } else {
1613                    // Done with this node
1614                    visited.insert(current.clone());
1615                    rec_stack.remove(&current);
1616                }
1617            }
1618        }
1619
1620        cycles
1621    }
1622
1623    /// Detect cycles for fixtures in a specific file.
1624    /// Returns cycles where the first fixture in the cycle is defined in the given file.
1625    /// Uses cached cycle detection results for efficiency.
1626    pub fn detect_fixture_cycles_in_file(
1627        &self,
1628        file_path: &Path,
1629    ) -> Vec<super::types::FixtureCycle> {
1630        let all_cycles = self.detect_fixture_cycles();
1631        all_cycles
1632            .iter()
1633            .filter(|cycle| cycle.fixture.file_path == file_path)
1634            .cloned()
1635            .collect()
1636    }
1637
1638    // ============ Scope Validation ============
1639
1640    /// Detect scope mismatches where a broader-scoped fixture depends on a narrower-scoped fixture.
1641    /// For example, a session-scoped fixture depending on a function-scoped fixture.
1642    /// Returns mismatches for fixtures defined in the given file.
1643    pub fn detect_scope_mismatches_in_file(
1644        &self,
1645        file_path: &Path,
1646    ) -> Vec<super::types::ScopeMismatch> {
1647        use super::types::ScopeMismatch;
1648
1649        let mut mismatches = Vec::new();
1650
1651        // Get fixtures defined in this file
1652        let Some(fixture_names) = self.file_definitions.get(file_path) else {
1653            return mismatches;
1654        };
1655
1656        for fixture_name in fixture_names.iter() {
1657            // Get the fixture definition
1658            let Some(definitions) = self.definitions.get(fixture_name) else {
1659                continue;
1660            };
1661
1662            // Find the definition in this file
1663            let Some(fixture_def) = definitions.iter().find(|d| d.file_path == file_path) else {
1664                continue;
1665            };
1666
1667            // Check each dependency
1668            for dep_name in &fixture_def.dependencies {
1669                // Find the dependency's definition (use resolution logic to get correct one)
1670                if let Some(dep_definitions) = self.definitions.get(dep_name) {
1671                    // Find best matching definition for the dependency
1672                    // Use the first one (most local) - matches cycle detection behavior
1673                    if let Some(dep_def) = dep_definitions.first() {
1674                        // Check if scope mismatch: fixture has broader scope than dependency
1675                        // FixtureScope is ordered: Function < Class < Module < Package < Session
1676                        if fixture_def.scope > dep_def.scope {
1677                            mismatches.push(ScopeMismatch {
1678                                fixture: fixture_def.clone(),
1679                                dependency: dep_def.clone(),
1680                            });
1681                        }
1682                    }
1683                }
1684            }
1685        }
1686
1687        mismatches
1688    }
1689
1690    /// Resolve a fixture by name for a given file using priority rules.
1691    ///
1692    /// Returns the best matching FixtureDefinition based on pytest's
1693    /// fixture shadowing rules: same file > conftest hierarchy > third-party.
1694    pub fn resolve_fixture_for_file(
1695        &self,
1696        file_path: &Path,
1697        fixture_name: &str,
1698    ) -> Option<FixtureDefinition> {
1699        let definitions = self.definitions.get(fixture_name)?;
1700
1701        // Priority 1: Same file
1702        if let Some(def) = definitions.iter().find(|d| d.file_path == file_path) {
1703            return Some(def.clone());
1704        }
1705
1706        // Priority 2: conftest.py in parent directories (closest first)
1707        let file_path = self.get_canonical_path(file_path.to_path_buf());
1708        let mut best_conftest: Option<&FixtureDefinition> = None;
1709        let mut best_depth = usize::MAX;
1710
1711        for def in definitions.iter() {
1712            if def.is_third_party {
1713                continue;
1714            }
1715            if def.file_path.ends_with("conftest.py") {
1716                if let Some(parent) = def.file_path.parent() {
1717                    if file_path.starts_with(parent) {
1718                        let depth = parent.components().count();
1719                        if depth > best_depth {
1720                            // Deeper = closer conftest
1721                            best_conftest = Some(def);
1722                            best_depth = depth;
1723                        } else if best_conftest.is_none() {
1724                            best_conftest = Some(def);
1725                            best_depth = depth;
1726                        }
1727                    }
1728                }
1729            }
1730        }
1731
1732        if let Some(def) = best_conftest {
1733            return Some(def.clone());
1734        }
1735
1736        // Priority 3: Plugin fixtures (pytest11 entry points)
1737        if let Some(def) = definitions
1738            .iter()
1739            .find(|d| d.is_plugin && !d.is_third_party)
1740        {
1741            return Some(def.clone());
1742        }
1743
1744        // Priority 4: Third-party (site-packages)
1745        if let Some(def) = definitions.iter().find(|d| d.is_third_party) {
1746            return Some(def.clone());
1747        }
1748
1749        // Fallback: first definition
1750        definitions.first().cloned()
1751    }
1752
1753    /// Find the name of the function/fixture containing a given line.
1754    ///
1755    /// Used for call hierarchy to identify callers.
1756    pub fn find_containing_function(&self, file_path: &Path, line: usize) -> Option<String> {
1757        let content = self.get_file_content(file_path)?;
1758
1759        // Use cached AST to avoid re-parsing
1760        let parsed = self.get_parsed_ast(file_path, &content)?;
1761
1762        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
1763            // Use cached line index for position calculations
1764            let line_index = self.get_line_index(file_path, &content);
1765
1766            for stmt in &module.body {
1767                if let Some(name) = self.find_function_containing_line(stmt, line, &line_index) {
1768                    return Some(name);
1769                }
1770            }
1771        }
1772
1773        None
1774    }
1775
1776    /// Recursively search for a function containing the given line.
1777    fn find_function_containing_line(
1778        &self,
1779        stmt: &Stmt,
1780        target_line: usize,
1781        line_index: &[usize],
1782    ) -> Option<String> {
1783        match stmt {
1784            Stmt::FunctionDef(func_def) => {
1785                let start_line =
1786                    self.get_line_from_offset(func_def.range.start().to_usize(), line_index);
1787                let end_line =
1788                    self.get_line_from_offset(func_def.range.end().to_usize(), line_index);
1789
1790                if target_line >= start_line && target_line <= end_line {
1791                    return Some(func_def.name.to_string());
1792                }
1793            }
1794            Stmt::AsyncFunctionDef(func_def) => {
1795                let start_line =
1796                    self.get_line_from_offset(func_def.range.start().to_usize(), line_index);
1797                let end_line =
1798                    self.get_line_from_offset(func_def.range.end().to_usize(), line_index);
1799
1800                if target_line >= start_line && target_line <= end_line {
1801                    return Some(func_def.name.to_string());
1802                }
1803            }
1804            Stmt::ClassDef(class_def) => {
1805                // Check methods inside the class
1806                for class_stmt in &class_def.body {
1807                    if let Some(name) =
1808                        self.find_function_containing_line(class_stmt, target_line, line_index)
1809                    {
1810                        return Some(name);
1811                    }
1812                }
1813            }
1814            _ => {}
1815        }
1816        None
1817    }
1818}