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