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::{Arguments, 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    ///
1319    /// Uses the cached AST as the primary path: finds the function definition at
1320    /// `function_line`, determines `needs_comma` from the AST argument list, and
1321    /// locates the closing `)` via paren-depth scanning of the raw source bytes.
1322    /// This correctly handles multi-line signatures and return-type annotations
1323    /// (`-> T:`), which the old `"):"`  string-matching approach could not.
1324    ///
1325    /// Falls back to a pure byte-scan when the AST is unavailable (e.g. the file
1326    /// has syntax errors), using the same `scan_for_signature_close_paren` helper.
1327    pub fn get_function_param_insertion_info(
1328        &self,
1329        file_path: &Path,
1330        function_line: usize,
1331    ) -> Option<ParamInsertionInfo> {
1332        let content = self.get_file_content(file_path)?;
1333        let line_index = self.get_line_index(file_path, &content);
1334        let bytes = content.as_bytes();
1335
1336        // ── AST path ─────────────────────────────────────────────────────────
1337        // Preferred: the AST gives accurate `needs_comma` (from the arg list)
1338        // and lets us scan from the exact `def` byte offset.
1339        if let Some(ast) = self.get_parsed_ast(file_path, &content) {
1340            if let rustpython_parser::ast::Mod::Module(module) = ast.as_ref() {
1341                if let Some(info) =
1342                    find_insertion_in_stmts(&module.body, function_line, bytes, &line_index)
1343                {
1344                    return Some(info);
1345                }
1346            }
1347        }
1348
1349        // ── String fallback ───────────────────────────────────────────────────
1350        // Used when the AST is unavailable (syntax errors) or the function was
1351        // not found in the module-level AST walk (should be rare).
1352        let def_line_start = *line_index
1353            .get(function_line.saturating_sub(1))
1354            .unwrap_or(&0);
1355        let close_paren = scan_for_signature_close_paren(bytes, def_line_start)?;
1356
1357        // Find the opening `(` to determine whether there are existing params.
1358        let open_paren = bytes[def_line_start..close_paren]
1359            .iter()
1360            .position(|&b| b == b'(')
1361            .map(|pos| def_line_start + pos)?;
1362
1363        // has_params: any non-whitespace, non-comment content between `(` and `)`.
1364        let between = &bytes[open_paren + 1..close_paren];
1365        let has_params = {
1366            let mut in_comment = false;
1367            between.iter().any(|&b| {
1368                if b == b'\n' {
1369                    in_comment = false;
1370                    return false;
1371                }
1372                if in_comment {
1373                    return false;
1374                }
1375                if b == b'#' {
1376                    in_comment = true;
1377                    return false;
1378                }
1379                !b.is_ascii_whitespace()
1380            })
1381        };
1382
1383        // Check whether `)` is on its own line (only whitespace before it on
1384        // that line).  If so, and there are existing params, use the multiline
1385        // insertion strategy: insert right after the last argument content
1386        // instead of at `)` itself.
1387        let close_paren_line = byte_offset_to_line_1based(close_paren, &line_index);
1388        if has_params {
1389            if let Some(ml) =
1390                try_multiline_insertion(close_paren, close_paren_line, bytes, &line_index)
1391            {
1392                return Some(ml);
1393            }
1394        }
1395
1396        Some(ParamInsertionInfo {
1397            line: close_paren_line,
1398            char_pos: byte_offset_to_col(close_paren, &line_index),
1399            needs_comma: has_params,
1400            multiline_indent: None,
1401        })
1402    }
1403
1404    /// Check if a position is inside a test or fixture function (parameter or body)
1405    /// Returns Some((function_name, is_fixture, declared_params)) if inside a function
1406    #[allow(dead_code)] // Used in tests
1407    pub fn is_inside_function(
1408        &self,
1409        file_path: &Path,
1410        line: u32,
1411        character: u32,
1412    ) -> Option<(String, bool, Vec<String>)> {
1413        // Try cache first, then file system
1414        let content = self.get_file_content(file_path)?;
1415
1416        let target_line = (line + 1) as usize; // Convert to 1-based
1417
1418        // Parse the file (using cached AST)
1419        let parsed = self.get_parsed_ast(file_path, &content)?;
1420
1421        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
1422            return self.find_enclosing_function(
1423                &module.body,
1424                &content,
1425                target_line,
1426                character as usize,
1427            );
1428        }
1429
1430        None
1431    }
1432
1433    #[allow(dead_code)]
1434    fn find_enclosing_function(
1435        &self,
1436        stmts: &[Stmt],
1437        content: &str,
1438        target_line: usize,
1439        _target_char: usize,
1440    ) -> Option<(String, bool, Vec<String>)> {
1441        let line_index = Self::build_line_index(content);
1442
1443        for stmt in stmts {
1444            match stmt {
1445                Stmt::FunctionDef(func_def) => {
1446                    let func_start_line =
1447                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
1448                    let func_end_line =
1449                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
1450
1451                    // Check if target is within this function's range
1452                    if target_line >= func_start_line && target_line <= func_end_line {
1453                        let is_fixture = func_def
1454                            .decorator_list
1455                            .iter()
1456                            .any(decorators::is_fixture_decorator);
1457                        let is_test = func_def.name.starts_with("test_");
1458
1459                        // Only return if it's a test or fixture
1460                        if is_test || is_fixture {
1461                            let params: Vec<String> = func_def
1462                                .args
1463                                .args
1464                                .iter()
1465                                .map(|arg| arg.def.arg.to_string())
1466                                .collect();
1467
1468                            return Some((func_def.name.to_string(), is_fixture, params));
1469                        }
1470                    }
1471                }
1472                Stmt::AsyncFunctionDef(func_def) => {
1473                    let func_start_line =
1474                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
1475                    let func_end_line =
1476                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
1477
1478                    if target_line >= func_start_line && target_line <= func_end_line {
1479                        let is_fixture = func_def
1480                            .decorator_list
1481                            .iter()
1482                            .any(decorators::is_fixture_decorator);
1483                        let is_test = func_def.name.starts_with("test_");
1484
1485                        if is_test || is_fixture {
1486                            let params: Vec<String> = func_def
1487                                .args
1488                                .args
1489                                .iter()
1490                                .map(|arg| arg.def.arg.to_string())
1491                                .collect();
1492
1493                            return Some((func_def.name.to_string(), is_fixture, params));
1494                        }
1495                    }
1496                }
1497                _ => {}
1498            }
1499        }
1500
1501        None
1502    }
1503
1504    // ============ Cycle Detection ============
1505
1506    /// Detect circular dependencies in fixtures with caching.
1507    /// Results are cached and only recomputed when definitions change.
1508    /// Returns Arc to avoid cloning the potentially large Vec.
1509    pub fn detect_fixture_cycles(&self) -> std::sync::Arc<Vec<super::types::FixtureCycle>> {
1510        use std::sync::Arc;
1511
1512        let current_version = self
1513            .definitions_version
1514            .load(std::sync::atomic::Ordering::SeqCst);
1515
1516        // Check cache first
1517        if let Some(cached) = self.cycle_cache.get(&()) {
1518            let (cached_version, cached_cycles) = cached.value();
1519            if *cached_version == current_version {
1520                return Arc::clone(cached_cycles);
1521            }
1522        }
1523
1524        // Compute cycles
1525        let cycles = Arc::new(self.compute_fixture_cycles());
1526
1527        // Store in cache
1528        self.cycle_cache
1529            .insert((), (current_version, Arc::clone(&cycles)));
1530
1531        cycles
1532    }
1533
1534    /// Actually compute fixture cycles using iterative DFS (Tarjan-like approach).
1535    /// Uses iterative algorithm to avoid stack overflow on deep dependency graphs.
1536    fn compute_fixture_cycles(&self) -> Vec<super::types::FixtureCycle> {
1537        use super::types::FixtureCycle;
1538        use std::collections::HashMap;
1539
1540        // Build dependency graph: fixture_name -> dependencies (only known fixtures)
1541        let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
1542        let mut fixture_defs: HashMap<String, FixtureDefinition> = HashMap::new();
1543
1544        for entry in self.definitions.iter() {
1545            let fixture_name = entry.key().clone();
1546            if let Some(def) = entry.value().first() {
1547                fixture_defs.insert(fixture_name.clone(), def.clone());
1548                // Only include dependencies that are known fixtures
1549                let valid_deps: Vec<String> = def
1550                    .dependencies
1551                    .iter()
1552                    .filter(|d| self.definitions.contains_key(*d))
1553                    .cloned()
1554                    .collect();
1555                dep_graph.insert(fixture_name, valid_deps);
1556            }
1557        }
1558
1559        let mut cycles = Vec::new();
1560        let mut visited: HashSet<String> = HashSet::new();
1561        let mut seen_cycles: HashSet<String> = HashSet::new(); // Deduplicate cycles
1562
1563        // Iterative DFS using explicit stack
1564        for start_fixture in dep_graph.keys() {
1565            if visited.contains(start_fixture) {
1566                continue;
1567            }
1568
1569            // Stack entries: (fixture_name, iterator_index, path_to_here)
1570            let mut stack: Vec<(String, usize, Vec<String>)> =
1571                vec![(start_fixture.clone(), 0, vec![])];
1572            let mut rec_stack: HashSet<String> = HashSet::new();
1573
1574            while let Some((current, idx, mut path)) = stack.pop() {
1575                if idx == 0 {
1576                    // First time visiting this node
1577                    if rec_stack.contains(&current) {
1578                        // Found a cycle
1579                        let cycle_start_idx = path.iter().position(|f| f == &current).unwrap_or(0);
1580                        let mut cycle_path: Vec<String> = path[cycle_start_idx..].to_vec();
1581                        cycle_path.push(current.clone());
1582
1583                        // Create a canonical key for deduplication (sorted cycle representation)
1584                        let mut cycle_key: Vec<String> =
1585                            cycle_path[..cycle_path.len() - 1].to_vec();
1586                        cycle_key.sort();
1587                        let cycle_key_str = cycle_key.join(",");
1588
1589                        if !seen_cycles.contains(&cycle_key_str) {
1590                            seen_cycles.insert(cycle_key_str);
1591                            if let Some(fixture_def) = fixture_defs.get(&current) {
1592                                cycles.push(FixtureCycle {
1593                                    cycle_path,
1594                                    fixture: fixture_def.clone(),
1595                                });
1596                            }
1597                        }
1598                        continue;
1599                    }
1600
1601                    rec_stack.insert(current.clone());
1602                    path.push(current.clone());
1603                }
1604
1605                // Get dependencies for current node
1606                let deps = match dep_graph.get(&current) {
1607                    Some(d) => d,
1608                    None => {
1609                        rec_stack.remove(&current);
1610                        continue;
1611                    }
1612                };
1613
1614                if idx < deps.len() {
1615                    // Push current back with next index
1616                    stack.push((current.clone(), idx + 1, path.clone()));
1617
1618                    let dep = &deps[idx];
1619                    if rec_stack.contains(dep) {
1620                        // Found a cycle through this dependency
1621                        let cycle_start_idx = path.iter().position(|f| f == dep).unwrap_or(0);
1622                        let mut cycle_path: Vec<String> = path[cycle_start_idx..].to_vec();
1623                        cycle_path.push(dep.clone());
1624
1625                        let mut cycle_key: Vec<String> =
1626                            cycle_path[..cycle_path.len() - 1].to_vec();
1627                        cycle_key.sort();
1628                        let cycle_key_str = cycle_key.join(",");
1629
1630                        if !seen_cycles.contains(&cycle_key_str) {
1631                            seen_cycles.insert(cycle_key_str);
1632                            if let Some(fixture_def) = fixture_defs.get(dep) {
1633                                cycles.push(FixtureCycle {
1634                                    cycle_path,
1635                                    fixture: fixture_def.clone(),
1636                                });
1637                            }
1638                        }
1639                    } else if !visited.contains(dep) {
1640                        // Explore this dependency
1641                        stack.push((dep.clone(), 0, path.clone()));
1642                    }
1643                } else {
1644                    // Done with this node
1645                    visited.insert(current.clone());
1646                    rec_stack.remove(&current);
1647                }
1648            }
1649        }
1650
1651        cycles
1652    }
1653
1654    /// Detect cycles for fixtures in a specific file.
1655    /// Returns cycles where the first fixture in the cycle is defined in the given file.
1656    /// Uses cached cycle detection results for efficiency.
1657    pub fn detect_fixture_cycles_in_file(
1658        &self,
1659        file_path: &Path,
1660    ) -> Vec<super::types::FixtureCycle> {
1661        let all_cycles = self.detect_fixture_cycles();
1662        all_cycles
1663            .iter()
1664            .filter(|cycle| cycle.fixture.file_path == file_path)
1665            .cloned()
1666            .collect()
1667    }
1668
1669    // ============ Scope Validation ============
1670
1671    /// Detect scope mismatches where a broader-scoped fixture depends on a narrower-scoped fixture.
1672    /// For example, a session-scoped fixture depending on a function-scoped fixture.
1673    /// Returns mismatches for fixtures defined in the given file.
1674    pub fn detect_scope_mismatches_in_file(
1675        &self,
1676        file_path: &Path,
1677    ) -> Vec<super::types::ScopeMismatch> {
1678        use super::types::ScopeMismatch;
1679
1680        let mut mismatches = Vec::new();
1681
1682        // Get fixtures defined in this file
1683        let Some(fixture_names) = self.file_definitions.get(file_path) else {
1684            return mismatches;
1685        };
1686
1687        for fixture_name in fixture_names.iter() {
1688            // Get the fixture definition
1689            let Some(definitions) = self.definitions.get(fixture_name) else {
1690                continue;
1691            };
1692
1693            // Find the definition in this file
1694            let Some(fixture_def) = definitions.iter().find(|d| d.file_path == file_path) else {
1695                continue;
1696            };
1697
1698            // Check each dependency
1699            for dep_name in &fixture_def.dependencies {
1700                // Find the dependency's definition (use resolution logic to get correct one)
1701                if let Some(dep_definitions) = self.definitions.get(dep_name) {
1702                    // Find best matching definition for the dependency
1703                    // Use the first one (most local) - matches cycle detection behavior
1704                    if let Some(dep_def) = dep_definitions.first() {
1705                        // Check if scope mismatch: fixture has broader scope than dependency
1706                        // FixtureScope is ordered: Function < Class < Module < Package < Session
1707                        if fixture_def.scope > dep_def.scope {
1708                            mismatches.push(ScopeMismatch {
1709                                fixture: fixture_def.clone(),
1710                                dependency: dep_def.clone(),
1711                            });
1712                        }
1713                    }
1714                }
1715            }
1716        }
1717
1718        mismatches
1719    }
1720
1721    /// Resolve a fixture by name for a given file using priority rules.
1722    ///
1723    /// Returns the best matching FixtureDefinition based on pytest's
1724    /// fixture shadowing rules: same file > conftest hierarchy > third-party.
1725    pub fn resolve_fixture_for_file(
1726        &self,
1727        file_path: &Path,
1728        fixture_name: &str,
1729    ) -> Option<FixtureDefinition> {
1730        let definitions = self.definitions.get(fixture_name)?;
1731
1732        // Priority 1: Same file
1733        if let Some(def) = definitions.iter().find(|d| d.file_path == file_path) {
1734            return Some(def.clone());
1735        }
1736
1737        // Priority 2: conftest.py in parent directories (closest first)
1738        let file_path = self.get_canonical_path(file_path.to_path_buf());
1739        let mut best_conftest: Option<&FixtureDefinition> = None;
1740        let mut best_depth = usize::MAX;
1741
1742        for def in definitions.iter() {
1743            if def.is_third_party {
1744                continue;
1745            }
1746            if def.file_path.ends_with("conftest.py") {
1747                if let Some(parent) = def.file_path.parent() {
1748                    if file_path.starts_with(parent) {
1749                        let depth = parent.components().count();
1750                        if depth > best_depth {
1751                            // Deeper = closer conftest
1752                            best_conftest = Some(def);
1753                            best_depth = depth;
1754                        } else if best_conftest.is_none() {
1755                            best_conftest = Some(def);
1756                            best_depth = depth;
1757                        }
1758                    }
1759                }
1760            }
1761        }
1762
1763        if let Some(def) = best_conftest {
1764            return Some(def.clone());
1765        }
1766
1767        // Priority 3: Plugin fixtures (pytest11 entry points)
1768        if let Some(def) = definitions
1769            .iter()
1770            .find(|d| d.is_plugin && !d.is_third_party)
1771        {
1772            return Some(def.clone());
1773        }
1774
1775        // Priority 4: Third-party (site-packages)
1776        if let Some(def) = definitions.iter().find(|d| d.is_third_party) {
1777            return Some(def.clone());
1778        }
1779
1780        // Fallback: first definition
1781        definitions.first().cloned()
1782    }
1783
1784    /// Find the name of the function/fixture containing a given line.
1785    ///
1786    /// Used for call hierarchy to identify callers.
1787    pub fn find_containing_function(&self, file_path: &Path, line: usize) -> Option<String> {
1788        let content = self.get_file_content(file_path)?;
1789
1790        // Use cached AST to avoid re-parsing
1791        let parsed = self.get_parsed_ast(file_path, &content)?;
1792
1793        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
1794            // Use cached line index for position calculations
1795            let line_index = self.get_line_index(file_path, &content);
1796
1797            for stmt in &module.body {
1798                if let Some(name) = self.find_function_containing_line(stmt, line, &line_index) {
1799                    return Some(name);
1800                }
1801            }
1802        }
1803
1804        None
1805    }
1806
1807    /// Recursively search for a function containing the given line.
1808    fn find_function_containing_line(
1809        &self,
1810        stmt: &Stmt,
1811        target_line: usize,
1812        line_index: &[usize],
1813    ) -> Option<String> {
1814        match stmt {
1815            Stmt::FunctionDef(func_def) => {
1816                let start_line =
1817                    self.get_line_from_offset(func_def.range.start().to_usize(), line_index);
1818                let end_line =
1819                    self.get_line_from_offset(func_def.range.end().to_usize(), line_index);
1820
1821                if target_line >= start_line && target_line <= end_line {
1822                    return Some(func_def.name.to_string());
1823                }
1824            }
1825            Stmt::AsyncFunctionDef(func_def) => {
1826                let start_line =
1827                    self.get_line_from_offset(func_def.range.start().to_usize(), line_index);
1828                let end_line =
1829                    self.get_line_from_offset(func_def.range.end().to_usize(), line_index);
1830
1831                if target_line >= start_line && target_line <= end_line {
1832                    return Some(func_def.name.to_string());
1833                }
1834            }
1835            Stmt::ClassDef(class_def) => {
1836                // Check methods inside the class
1837                for class_stmt in &class_def.body {
1838                    if let Some(name) =
1839                        self.find_function_containing_line(class_stmt, target_line, line_index)
1840                    {
1841                        return Some(name);
1842                    }
1843                }
1844            }
1845            _ => {}
1846        }
1847        None
1848    }
1849}
1850
1851// ── Free helpers for get_function_param_insertion_info ───────────────────────
1852
1853/// Scan `bytes` starting from `start`, tracking paren depth, to find the byte
1854/// offset of the closing `)` that matches the first `(` encountered.
1855///
1856/// Properly skips:
1857/// - String literals (single, double, and triple-quoted) so that `)` inside a
1858///   default value like `def f(x=")")` is never counted.
1859/// - Inline comments (`#` to end-of-line).
1860///
1861/// Returns `None` if no matching `)` is found within `bytes`.
1862fn scan_for_signature_close_paren(bytes: &[u8], start: usize) -> Option<usize> {
1863    let mut i = start;
1864    let mut depth: i32 = 0;
1865    let mut found_open = false;
1866
1867    while i < bytes.len() {
1868        match bytes[i] {
1869            b'#' => {
1870                // Inline comment: skip to end of line.
1871                while i < bytes.len() && bytes[i] != b'\n' {
1872                    i += 1;
1873                }
1874            }
1875            b'"' | b'\'' => {
1876                let q = bytes[i];
1877                // Triple-quoted string?
1878                if i + 2 < bytes.len() && bytes[i + 1] == q && bytes[i + 2] == q {
1879                    i += 3;
1880                    while i < bytes.len() {
1881                        if i + 2 < bytes.len()
1882                            && bytes[i] == q
1883                            && bytes[i + 1] == q
1884                            && bytes[i + 2] == q
1885                        {
1886                            i += 3;
1887                            break;
1888                        }
1889                        i += 1;
1890                    }
1891                } else {
1892                    // Single-quoted string.
1893                    i += 1;
1894                    while i < bytes.len() {
1895                        if bytes[i] == b'\\' {
1896                            i += 2; // skip escaped char
1897                        } else if bytes[i] == q {
1898                            i += 1; // consume closing quote
1899                            break;
1900                        } else {
1901                            i += 1;
1902                        }
1903                    }
1904                }
1905            }
1906            b'(' => {
1907                depth += 1;
1908                found_open = true;
1909                i += 1;
1910            }
1911            b')' if found_open => {
1912                depth -= 1;
1913                if depth == 0 {
1914                    return Some(i);
1915                }
1916                i += 1;
1917            }
1918            _ => {
1919                i += 1;
1920            }
1921        }
1922    }
1923
1924    None
1925}
1926
1927/// Convert a byte offset to a 1-based line number using `line_index`.
1928///
1929/// `line_index` is built by `FixtureDatabase::build_line_index`: it starts with
1930/// `0` (byte 0 = start of line 1) and then stores the byte offset of the first
1931/// character of each subsequent line.
1932fn byte_offset_to_line_1based(offset: usize, line_index: &[usize]) -> usize {
1933    match line_index.binary_search(&offset) {
1934        Ok(line) => line + 1,
1935        Err(line) => line,
1936    }
1937}
1938
1939/// Convert a byte offset to a 0-based column within its line.
1940fn byte_offset_to_col(offset: usize, line_index: &[usize]) -> usize {
1941    let line = byte_offset_to_line_1based(offset, line_index);
1942    // line_index[line - 1] is the byte start of that 1-based line.
1943    offset - line_index[line.saturating_sub(1)]
1944}
1945
1946/// Recursively walk `stmts` looking for a function definition whose `def`
1947/// keyword is on `function_line` (1-based).  Returns `ParamInsertionInfo`
1948/// when the function is found.
1949fn find_insertion_in_stmts(
1950    stmts: &[Stmt],
1951    function_line: usize,
1952    bytes: &[u8],
1953    line_index: &[usize],
1954) -> Option<ParamInsertionInfo> {
1955    for stmt in stmts {
1956        if let Some(info) = find_insertion_in_stmt(stmt, function_line, bytes, line_index) {
1957            return Some(info);
1958        }
1959    }
1960    None
1961}
1962
1963/// Match a single statement, recursing into function/class bodies as needed.
1964fn find_insertion_in_stmt(
1965    stmt: &Stmt,
1966    function_line: usize,
1967    bytes: &[u8],
1968    line_index: &[usize],
1969) -> Option<ParamInsertionInfo> {
1970    match stmt {
1971        Stmt::FunctionDef(f) => {
1972            let def_start = f.range.start().to_usize();
1973            if byte_offset_to_line_1based(def_start, line_index) == function_line {
1974                return param_insertion_from_args(def_start, &f.args, bytes, line_index);
1975            }
1976            // Recurse into the function body (handles nested functions).
1977            find_insertion_in_stmts(&f.body, function_line, bytes, line_index)
1978        }
1979        Stmt::AsyncFunctionDef(f) => {
1980            let def_start = f.range.start().to_usize();
1981            if byte_offset_to_line_1based(def_start, line_index) == function_line {
1982                return param_insertion_from_args(def_start, &f.args, bytes, line_index);
1983            }
1984            find_insertion_in_stmts(&f.body, function_line, bytes, line_index)
1985        }
1986        Stmt::ClassDef(c) => {
1987            // Recurse into the class body to find test methods.
1988            find_insertion_in_stmts(&c.body, function_line, bytes, line_index)
1989        }
1990        _ => None,
1991    }
1992}
1993
1994/// Given the byte offset of a `def` keyword and the function's AST `Arguments`,
1995/// scan the raw source bytes from `def_start` to find the closing `)` and build
1996/// a `ParamInsertionInfo`.
1997///
1998/// `has_params` (`needs_comma`) comes from the AST arg list, which correctly
1999/// handles all argument forms (`*args`, `**kwargs`, keyword-only, etc.).
2000fn param_insertion_from_args(
2001    def_start: usize,
2002    args: &Arguments,
2003    bytes: &[u8],
2004    line_index: &[usize],
2005) -> Option<ParamInsertionInfo> {
2006    let has_params = !args.posonlyargs.is_empty()
2007        || !args.args.is_empty()
2008        || !args.kwonlyargs.is_empty()
2009        || args.vararg.is_some()
2010        || args.kwarg.is_some();
2011
2012    let close_paren = scan_for_signature_close_paren(bytes, def_start)?;
2013    let close_paren_line = byte_offset_to_line_1based(close_paren, line_index);
2014
2015    // When `)` sits on its own line and there are existing params, use the
2016    // multiline-paren insertion strategy so that the comma ends up after the
2017    // last argument rather than before `)`.
2018    if has_params {
2019        if let Some(ml) = try_multiline_insertion(close_paren, close_paren_line, bytes, line_index)
2020        {
2021            return Some(ml);
2022        }
2023    }
2024
2025    Some(ParamInsertionInfo {
2026        line: close_paren_line,
2027        char_pos: byte_offset_to_col(close_paren, line_index),
2028        needs_comma: has_params,
2029        multiline_indent: None,
2030    })
2031}
2032
2033/// If `close_paren` is on a line that contains only whitespace before it
2034/// (i.e. `)` is on its own line), return a `ParamInsertionInfo` whose
2035/// insertion point is right after the last non-whitespace byte before `)`.
2036///
2037/// This ensures that for multiline signatures like
2038/// ```python
2039/// def test_foo(
2040///     fixture_a: TypeA,
2041///     fixture_b: TypeB,   ← last content; trailing comma present
2042/// ):
2043/// ```
2044/// the new parameter is appended as `\n    new_fixture,` rather than
2045/// `, new_fixture` being injected in front of the lone `)`.
2046///
2047/// Returns `None` when `)` is NOT on its own line (single-line or inline-paren
2048/// signature), telling callers to fall back to the classic strategy.
2049fn try_multiline_insertion(
2050    close_paren: usize,
2051    close_paren_line: usize,
2052    bytes: &[u8],
2053    line_index: &[usize],
2054) -> Option<ParamInsertionInfo> {
2055    // `)` must be on its own line — only whitespace may precede it on that line.
2056    let line_start = line_index[close_paren_line - 1];
2057    let only_ws = bytes[line_start..close_paren]
2058        .iter()
2059        .all(|&b| b == b' ' || b == b'\t');
2060    if !only_ws {
2061        return None;
2062    }
2063
2064    // Scan backwards from the byte just before `)` to find the last
2065    // non-whitespace byte.  That is either a `,` (trailing comma) or the
2066    // last character of the final argument.
2067    let last_content_pos = find_last_content_before(bytes, close_paren)?;
2068    let has_trailing_comma = bytes[last_content_pos] == b',';
2069
2070    // Determine the indentation for the new parameter by looking at the
2071    // leading whitespace of the line that contains `last_content_pos`.
2072    let indent = indent_of_line_at(bytes, last_content_pos, line_index);
2073
2074    // Insert point: the byte immediately after `last_content_pos`.
2075    // For `    fixture_b: TypeB,\n` that's right after the `,`, before `\n`.
2076    let insert_offset = last_content_pos + 1;
2077    let insert_line = byte_offset_to_line_1based(insert_offset, line_index);
2078    let insert_col = byte_offset_to_col(insert_offset, line_index);
2079
2080    Some(ParamInsertionInfo {
2081        line: insert_line,
2082        char_pos: insert_col,
2083        // If there is already a trailing comma we only need to emit the
2084        // newline + indent + new param (+ trailing comma to mirror style).
2085        // If there is no trailing comma we must first add `,` after the last
2086        // argument and then emit the newline + indent + new param.
2087        needs_comma: !has_trailing_comma,
2088        multiline_indent: Some(indent),
2089    })
2090}
2091
2092/// Scan backwards from `before` (exclusive) through `bytes`, skipping ASCII
2093/// whitespace (`' '`, `'\t'`, `'\n'`, `'\r'`), and return the byte offset of
2094/// the first non-whitespace byte found, or `None` if only whitespace precedes
2095/// the position.
2096fn find_last_content_before(bytes: &[u8], before: usize) -> Option<usize> {
2097    let mut pos = before;
2098    while pos > 0 {
2099        pos -= 1;
2100        match bytes[pos] {
2101            b' ' | b'\t' | b'\n' | b'\r' => continue,
2102            _ => return Some(pos),
2103        }
2104    }
2105    None
2106}
2107
2108/// Return the leading-whitespace string of the line that contains `byte_pos`.
2109fn indent_of_line_at(bytes: &[u8], byte_pos: usize, line_index: &[usize]) -> String {
2110    let line_1based = byte_offset_to_line_1based(byte_pos, line_index);
2111    let line_start = line_index[line_1based - 1];
2112    let indent_len = bytes[line_start..]
2113        .iter()
2114        .take_while(|&&b| b == b' ' || b == b'\t')
2115        .count();
2116    String::from_utf8_lossy(&bytes[line_start..line_start + indent_len]).into_owned()
2117}