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