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    /// Public method to get the fixture definition at a specific line and name
94    pub fn get_definition_at_line(
95        &self,
96        file_path: &Path,
97        line: usize,
98        fixture_name: &str,
99    ) -> Option<FixtureDefinition> {
100        if let Some(definitions) = self.definitions.get(fixture_name) {
101            for def in definitions.iter() {
102                if def.file_path == file_path && def.line == line {
103                    return Some(def.clone());
104                }
105            }
106        }
107        None
108    }
109
110    /// Find the closest fixture definition based on pytest priority rules.
111    pub(crate) fn find_closest_definition(
112        &self,
113        file_path: &Path,
114        fixture_name: &str,
115    ) -> Option<FixtureDefinition> {
116        self.find_closest_definition_with_filter(file_path, fixture_name, |_| true)
117    }
118
119    /// Find the closest definition, excluding a specific definition.
120    pub(crate) fn find_closest_definition_excluding(
121        &self,
122        file_path: &Path,
123        fixture_name: &str,
124        exclude: Option<&FixtureDefinition>,
125    ) -> Option<FixtureDefinition> {
126        self.find_closest_definition_with_filter(file_path, fixture_name, |def| {
127            if let Some(excluded) = exclude {
128                def != excluded
129            } else {
130                true
131            }
132        })
133    }
134
135    /// Internal helper that implements pytest priority rules with a custom filter.
136    /// Priority order:
137    /// 1. Same file (highest priority, last definition wins)
138    /// 2. Closest conftest.py in parent directories
139    /// 3. Third-party fixtures from site-packages
140    fn find_closest_definition_with_filter<F>(
141        &self,
142        file_path: &Path,
143        fixture_name: &str,
144        filter: F,
145    ) -> Option<FixtureDefinition>
146    where
147        F: Fn(&FixtureDefinition) -> bool,
148    {
149        let definitions = self.definitions.get(fixture_name)?;
150
151        // Priority 1: Same file (highest priority)
152        debug!(
153            "Checking for fixture {} in same file: {:?}",
154            fixture_name, file_path
155        );
156
157        if let Some(last_def) = definitions
158            .iter()
159            .filter(|def| def.file_path == file_path && filter(def))
160            .max_by_key(|def| def.line)
161        {
162            info!(
163                "Found fixture {} in same file at line {}",
164                fixture_name, last_def.line
165            );
166            return Some(last_def.clone());
167        }
168
169        // Priority 2: Search upward through conftest.py files
170        let mut current_dir = file_path.parent()?;
171
172        debug!(
173            "Searching for fixture {} in conftest.py files starting from {:?}",
174            fixture_name, current_dir
175        );
176        loop {
177            let conftest_path = current_dir.join("conftest.py");
178            debug!("  Checking conftest.py at: {:?}", conftest_path);
179
180            for def in definitions.iter() {
181                if def.file_path == conftest_path && filter(def) {
182                    info!(
183                        "Found fixture {} in conftest.py: {:?}",
184                        fixture_name, conftest_path
185                    );
186                    return Some(def.clone());
187                }
188            }
189
190            match current_dir.parent() {
191                Some(parent) => current_dir = parent,
192                None => break,
193            }
194        }
195
196        // Priority 3: Third-party fixtures (site-packages)
197        debug!(
198            "No fixture {} found in conftest hierarchy, checking third-party",
199            fixture_name
200        );
201        for def in definitions.iter() {
202            if def.is_third_party && filter(def) {
203                info!(
204                    "Found third-party fixture {} in site-packages: {:?}",
205                    fixture_name, def.file_path
206                );
207                return Some(def.clone());
208            }
209        }
210
211        debug!(
212            "No fixture {} found in scope for {:?}",
213            fixture_name, file_path
214        );
215        None
216    }
217
218    /// Find the fixture name at a given position (either definition or usage)
219    pub fn find_fixture_at_position(
220        &self,
221        file_path: &Path,
222        line: u32,
223        character: u32,
224    ) -> Option<String> {
225        let target_line = (line + 1) as usize;
226
227        debug!(
228            "find_fixture_at_position: file={:?}, line={}, char={}",
229            file_path, target_line, character
230        );
231
232        let content = self.get_file_content(file_path)?;
233        let line_content = content.lines().nth(target_line.saturating_sub(1))?;
234        debug!("Line content: {}", line_content);
235
236        let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
237        debug!("Word at cursor: {:?}", word_at_cursor);
238
239        // Check if this word matches any fixture usage on this line
240        if let Some(usages) = self.usages.get(file_path) {
241            for usage in usages.iter() {
242                if usage.line == target_line {
243                    let cursor_pos = character as usize;
244                    if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
245                        debug!(
246                            "Cursor at {} is within usage range {}-{}: {}",
247                            cursor_pos, usage.start_char, usage.end_char, usage.name
248                        );
249                        info!("Found fixture usage at cursor position: {}", usage.name);
250                        return Some(usage.name.clone());
251                    }
252                }
253            }
254        }
255
256        // Check if we're on a fixture definition line
257        for entry in self.definitions.iter() {
258            for def in entry.value().iter() {
259                if def.file_path == file_path && def.line == target_line {
260                    if let Some(ref word) = word_at_cursor {
261                        if word == &def.name {
262                            info!(
263                                "Found fixture definition name at cursor position: {}",
264                                def.name
265                            );
266                            return Some(def.name.clone());
267                        }
268                    }
269                }
270            }
271        }
272
273        debug!("No fixture found at cursor position");
274        None
275    }
276
277    /// Extract the word at a given character position in a line
278    pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
279        super::string_utils::extract_word_at_position(line, character)
280    }
281
282    /// Find all references (usages) of a fixture by name
283    pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
284        info!("Finding all references for fixture: {}", fixture_name);
285
286        let mut all_references = Vec::new();
287
288        for entry in self.usages.iter() {
289            let file_path = entry.key();
290            let usages = entry.value();
291
292            for usage in usages.iter() {
293                if usage.name == fixture_name {
294                    debug!(
295                        "Found reference to {} in {:?} at line {}",
296                        fixture_name, file_path, usage.line
297                    );
298                    all_references.push(usage.clone());
299                }
300            }
301        }
302
303        info!(
304            "Found {} total references for fixture: {}",
305            all_references.len(),
306            fixture_name
307        );
308        all_references
309    }
310
311    /// Find all references that resolve to a specific fixture definition
312    pub fn find_references_for_definition(
313        &self,
314        definition: &FixtureDefinition,
315    ) -> Vec<FixtureUsage> {
316        info!(
317            "Finding references for specific definition: {} at {:?}:{}",
318            definition.name, definition.file_path, definition.line
319        );
320
321        let mut matching_references = Vec::new();
322
323        for entry in self.usages.iter() {
324            let file_path = entry.key();
325            let usages = entry.value();
326
327            for usage in usages.iter() {
328                if usage.name == definition.name {
329                    let fixture_def_at_line =
330                        self.get_fixture_definition_at_line(file_path, usage.line);
331
332                    let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
333                        if current_def.name == usage.name {
334                            debug!(
335                                "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
336                                file_path, usage.line, current_def.line
337                            );
338                            self.find_closest_definition_excluding(
339                                file_path,
340                                &usage.name,
341                                Some(current_def),
342                            )
343                        } else {
344                            self.find_closest_definition(file_path, &usage.name)
345                        }
346                    } else {
347                        self.find_closest_definition(file_path, &usage.name)
348                    };
349
350                    if let Some(resolved_def) = resolved_def {
351                        if resolved_def == *definition {
352                            debug!(
353                                "Usage at {:?}:{} resolves to our definition",
354                                file_path, usage.line
355                            );
356                            matching_references.push(usage.clone());
357                        } else {
358                            debug!(
359                                "Usage at {:?}:{} resolves to different definition at {:?}:{}",
360                                file_path, usage.line, resolved_def.file_path, resolved_def.line
361                            );
362                        }
363                    }
364                }
365            }
366        }
367
368        info!(
369            "Found {} references that resolve to this specific definition",
370            matching_references.len()
371        );
372        matching_references
373    }
374
375    /// Get all undeclared fixture usages for a file
376    pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
377        self.undeclared_fixtures
378            .get(file_path)
379            .map(|entry| entry.value().clone())
380            .unwrap_or_default()
381    }
382
383    /// Get all available fixtures for a given file
384    pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
385        let mut available_fixtures = Vec::new();
386        let mut seen_names = HashSet::new();
387
388        // Priority 1: Fixtures in the same file
389        for entry in self.definitions.iter() {
390            let fixture_name = entry.key();
391            for def in entry.value().iter() {
392                if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
393                    available_fixtures.push(def.clone());
394                    seen_names.insert(fixture_name.clone());
395                }
396            }
397        }
398
399        // Priority 2: Fixtures in conftest.py files
400        if let Some(mut current_dir) = file_path.parent() {
401            loop {
402                let conftest_path = current_dir.join("conftest.py");
403
404                for entry in self.definitions.iter() {
405                    let fixture_name = entry.key();
406                    for def in entry.value().iter() {
407                        if def.file_path == conftest_path
408                            && !seen_names.contains(fixture_name.as_str())
409                        {
410                            available_fixtures.push(def.clone());
411                            seen_names.insert(fixture_name.clone());
412                        }
413                    }
414                }
415
416                match current_dir.parent() {
417                    Some(parent) => current_dir = parent,
418                    None => break,
419                }
420            }
421        }
422
423        // Priority 3: Third-party fixtures from site-packages
424        for entry in self.definitions.iter() {
425            let fixture_name = entry.key();
426            for def in entry.value().iter() {
427                if def.is_third_party && !seen_names.contains(fixture_name.as_str()) {
428                    available_fixtures.push(def.clone());
429                    seen_names.insert(fixture_name.clone());
430                }
431            }
432        }
433
434        available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
435        available_fixtures
436    }
437
438    /// Get the completion context for a given position
439    pub fn get_completion_context(
440        &self,
441        file_path: &Path,
442        line: u32,
443        character: u32,
444    ) -> Option<CompletionContext> {
445        let content = self.get_file_content(file_path)?;
446        let target_line = (line + 1) as usize;
447        let line_index = self.get_line_index(file_path, &content);
448
449        let parsed = self.get_parsed_ast(file_path, &content)?;
450
451        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
452            // First check if we're inside a decorator
453            if let Some(ctx) =
454                self.check_decorator_context(&module.body, &content, target_line, &line_index)
455            {
456                return Some(ctx);
457            }
458
459            // Then check for function context
460            return self.get_function_completion_context(
461                &module.body,
462                &content,
463                target_line,
464                character as usize,
465                &line_index,
466            );
467        }
468
469        None
470    }
471
472    /// Check if the cursor is inside a decorator that needs fixture completions
473    fn check_decorator_context(
474        &self,
475        stmts: &[Stmt],
476        _content: &str,
477        target_line: usize,
478        line_index: &[usize],
479    ) -> Option<CompletionContext> {
480        for stmt in stmts {
481            let decorator_list = match stmt {
482                Stmt::FunctionDef(f) => &f.decorator_list,
483                Stmt::AsyncFunctionDef(f) => &f.decorator_list,
484                Stmt::ClassDef(c) => &c.decorator_list,
485                _ => continue,
486            };
487
488            for decorator in decorator_list {
489                let dec_start_line =
490                    self.get_line_from_offset(decorator.range().start().to_usize(), line_index);
491                let dec_end_line =
492                    self.get_line_from_offset(decorator.range().end().to_usize(), line_index);
493
494                if target_line >= dec_start_line && target_line <= dec_end_line {
495                    if decorators::is_usefixtures_decorator(decorator) {
496                        return Some(CompletionContext::UsefixuturesDecorator);
497                    }
498                    if decorators::is_parametrize_decorator(decorator) {
499                        return Some(CompletionContext::ParametrizeIndirect);
500                    }
501                }
502            }
503
504            // Recursively check class bodies
505            if let Stmt::ClassDef(class_def) = stmt {
506                if let Some(ctx) =
507                    self.check_decorator_context(&class_def.body, _content, target_line, line_index)
508                {
509                    return Some(ctx);
510                }
511            }
512        }
513
514        None
515    }
516
517    /// Get completion context when cursor is inside a function
518    fn get_function_completion_context(
519        &self,
520        stmts: &[Stmt],
521        content: &str,
522        target_line: usize,
523        target_char: usize,
524        line_index: &[usize],
525    ) -> Option<CompletionContext> {
526        for stmt in stmts {
527            match stmt {
528                Stmt::FunctionDef(func_def) => {
529                    if let Some(ctx) = self.get_func_context(
530                        &func_def.name,
531                        &func_def.decorator_list,
532                        &func_def.args,
533                        func_def.range,
534                        content,
535                        target_line,
536                        target_char,
537                        line_index,
538                    ) {
539                        return Some(ctx);
540                    }
541                }
542                Stmt::AsyncFunctionDef(func_def) => {
543                    if let Some(ctx) = self.get_func_context(
544                        &func_def.name,
545                        &func_def.decorator_list,
546                        &func_def.args,
547                        func_def.range,
548                        content,
549                        target_line,
550                        target_char,
551                        line_index,
552                    ) {
553                        return Some(ctx);
554                    }
555                }
556                Stmt::ClassDef(class_def) => {
557                    if let Some(ctx) = self.get_function_completion_context(
558                        &class_def.body,
559                        content,
560                        target_line,
561                        target_char,
562                        line_index,
563                    ) {
564                        return Some(ctx);
565                    }
566                }
567                _ => {}
568            }
569        }
570
571        None
572    }
573
574    /// Helper to get function completion context
575    #[allow(clippy::too_many_arguments)]
576    fn get_func_context(
577        &self,
578        func_name: &rustpython_parser::ast::Identifier,
579        decorator_list: &[Expr],
580        args: &rustpython_parser::ast::Arguments,
581        range: rustpython_parser::text_size::TextRange,
582        content: &str,
583        target_line: usize,
584        _target_char: usize,
585        line_index: &[usize],
586    ) -> Option<CompletionContext> {
587        let func_start_line = self.get_line_from_offset(range.start().to_usize(), line_index);
588        let func_end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
589
590        if target_line < func_start_line || target_line > func_end_line {
591            return None;
592        }
593
594        let is_fixture = decorator_list.iter().any(decorators::is_fixture_decorator);
595        let is_test = func_name.as_str().starts_with("test_");
596
597        if !is_test && !is_fixture {
598            return None;
599        }
600
601        // Collect all parameters
602        let params: Vec<String> = FixtureDatabase::all_args(args)
603            .map(|arg| arg.def.arg.to_string())
604            .collect();
605
606        // Find the line where the function signature ends
607        let lines: Vec<&str> = content.lines().collect();
608
609        let mut sig_end_line = func_start_line;
610        for (i, line) in lines
611            .iter()
612            .enumerate()
613            .skip(func_start_line.saturating_sub(1))
614        {
615            if line.contains("):") {
616                sig_end_line = i + 1;
617                break;
618            }
619            if i + 1 > func_start_line + 10 {
620                break;
621            }
622        }
623
624        let in_signature = target_line <= sig_end_line;
625
626        let context = if in_signature {
627            CompletionContext::FunctionSignature {
628                function_name: func_name.to_string(),
629                function_line: func_start_line,
630                is_fixture,
631                declared_params: params,
632            }
633        } else {
634            CompletionContext::FunctionBody {
635                function_name: func_name.to_string(),
636                function_line: func_start_line,
637                is_fixture,
638                declared_params: params,
639            }
640        };
641
642        Some(context)
643    }
644
645    /// Get information about where to insert a new parameter in a function signature
646    pub fn get_function_param_insertion_info(
647        &self,
648        file_path: &Path,
649        function_line: usize,
650    ) -> Option<ParamInsertionInfo> {
651        let content = self.get_file_content(file_path)?;
652        let lines: Vec<&str> = content.lines().collect();
653
654        for i in (function_line.saturating_sub(1))..lines.len().min(function_line + 10) {
655            let line = lines[i];
656            if let Some(paren_pos) = line.find("):") {
657                let has_params = if let Some(open_pos) = line.find('(') {
658                    if open_pos < paren_pos {
659                        let params_section = &line[open_pos + 1..paren_pos];
660                        !params_section.trim().is_empty()
661                    } else {
662                        true
663                    }
664                } else {
665                    let before_close = &line[..paren_pos];
666                    if !before_close.trim().is_empty() {
667                        true
668                    } else {
669                        let mut found_params = false;
670                        for prev_line in lines.iter().take(i).skip(function_line.saturating_sub(1))
671                        {
672                            if prev_line.contains('(') {
673                                if let Some(open_pos) = prev_line.find('(') {
674                                    let after_open = &prev_line[open_pos + 1..];
675                                    if !after_open.trim().is_empty() {
676                                        found_params = true;
677                                        break;
678                                    }
679                                }
680                            } else if !prev_line.trim().is_empty() {
681                                found_params = true;
682                                break;
683                            }
684                        }
685                        found_params
686                    }
687                };
688
689                return Some(ParamInsertionInfo {
690                    line: i + 1,
691                    char_pos: paren_pos,
692                    needs_comma: has_params,
693                });
694            }
695        }
696
697        None
698    }
699
700    /// Check if a position is inside a test or fixture function (parameter or body)
701    /// Returns Some((function_name, is_fixture, declared_params)) if inside a function
702    #[allow(dead_code)] // Used in tests
703    #[allow(dead_code)] // Used in tests
704    pub fn is_inside_function(
705        &self,
706        file_path: &Path,
707        line: u32,
708        character: u32,
709    ) -> Option<(String, bool, Vec<String>)> {
710        // Try cache first, then file system
711        let content = self.get_file_content(file_path)?;
712
713        let target_line = (line + 1) as usize; // Convert to 1-based
714
715        // Parse the file (using cached AST)
716        let parsed = self.get_parsed_ast(file_path, &content)?;
717
718        if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
719            return self.find_enclosing_function(
720                &module.body,
721                &content,
722                target_line,
723                character as usize,
724            );
725        }
726
727        None
728    }
729
730    #[allow(dead_code)]
731    fn find_enclosing_function(
732        &self,
733        stmts: &[Stmt],
734        content: &str,
735        target_line: usize,
736        _target_char: usize,
737    ) -> Option<(String, bool, Vec<String>)> {
738        let line_index = Self::build_line_index(content);
739
740        for stmt in stmts {
741            match stmt {
742                Stmt::FunctionDef(func_def) => {
743                    let func_start_line =
744                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
745                    let func_end_line =
746                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
747
748                    // Check if target is within this function's range
749                    if target_line >= func_start_line && target_line <= func_end_line {
750                        let is_fixture = func_def
751                            .decorator_list
752                            .iter()
753                            .any(decorators::is_fixture_decorator);
754                        let is_test = func_def.name.starts_with("test_");
755
756                        // Only return if it's a test or fixture
757                        if is_test || is_fixture {
758                            let params: Vec<String> = func_def
759                                .args
760                                .args
761                                .iter()
762                                .map(|arg| arg.def.arg.to_string())
763                                .collect();
764
765                            return Some((func_def.name.to_string(), is_fixture, params));
766                        }
767                    }
768                }
769                Stmt::AsyncFunctionDef(func_def) => {
770                    let func_start_line =
771                        self.get_line_from_offset(func_def.range.start().to_usize(), &line_index);
772                    let func_end_line =
773                        self.get_line_from_offset(func_def.range.end().to_usize(), &line_index);
774
775                    if target_line >= func_start_line && target_line <= func_end_line {
776                        let is_fixture = func_def
777                            .decorator_list
778                            .iter()
779                            .any(decorators::is_fixture_decorator);
780                        let is_test = func_def.name.starts_with("test_");
781
782                        if is_test || is_fixture {
783                            let params: Vec<String> = func_def
784                                .args
785                                .args
786                                .iter()
787                                .map(|arg| arg.def.arg.to_string())
788                                .collect();
789
790                            return Some((func_def.name.to_string(), is_fixture, params));
791                        }
792                    }
793                }
794                _ => {}
795            }
796        }
797
798        None
799    }
800}