Skip to main content

pytest_language_server/fixtures/
analyzer.rs

1//! File analysis and AST parsing for fixture extraction.
2//!
3//! This module contains the core logic for parsing Python files and extracting
4//! fixture definitions and usages. Docstring extraction is in `docstring.rs`
5//! and undeclared fixture scanning is in `undeclared.rs`.
6
7use super::decorators;
8use super::types::{FixtureDefinition, FixtureScope, FixtureUsage};
9use super::FixtureDatabase;
10use rustpython_parser::ast::{ArgWithDefault, Arguments, Expr, Stmt};
11use rustpython_parser::{parse, Mode};
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use tracing::{debug, info};
15
16impl FixtureDatabase {
17    /// Analyze a Python file for fixtures and usages.
18    /// This is the public API - it cleans up previous definitions before analyzing.
19    pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
20        self.analyze_file_internal(file_path, content, true);
21    }
22
23    /// Analyze a file without cleaning up previous definitions.
24    /// Used during initial workspace scan when we know the database is empty.
25    pub(crate) fn analyze_file_fresh(&self, file_path: PathBuf, content: &str) {
26        self.analyze_file_internal(file_path, content, false);
27    }
28
29    /// Internal file analysis with optional cleanup of previous definitions
30    fn analyze_file_internal(&self, file_path: PathBuf, content: &str, cleanup_previous: bool) {
31        // Use cached canonical path to avoid repeated filesystem calls
32        let file_path = self.get_canonical_path(file_path);
33
34        debug!("Analyzing file: {:?}", file_path);
35
36        // Cache the file content for later use (e.g., in find_fixture_definition)
37        // Use Arc for efficient sharing without cloning
38        self.file_cache
39            .insert(file_path.clone(), std::sync::Arc::new(content.to_string()));
40
41        // Parse the Python code
42        let parsed = match parse(content, Mode::Module, "") {
43            Ok(ast) => ast,
44            Err(e) => {
45                // Keep existing fixture data when parse fails (user is likely editing)
46                // This provides better LSP experience during editing with syntax errors
47                debug!(
48                    "Failed to parse Python file {:?}: {} - keeping previous data",
49                    file_path, e
50                );
51                return;
52            }
53        };
54
55        // Clear previous usages for this file (only after successful parse)
56        self.cleanup_usages_for_file(&file_path);
57        self.usages.remove(&file_path);
58
59        // Clear previous undeclared fixtures for this file
60        self.undeclared_fixtures.remove(&file_path);
61
62        // Clear previous imports for this file
63        self.imports.remove(&file_path);
64
65        // Note: line_index_cache uses content-hash-based invalidation,
66        // so we don't need to clear it here - get_line_index will detect
67        // if the content has changed and rebuild if necessary.
68
69        // Clear previous fixture definitions from this file (only when re-analyzing)
70        // Skip this during initial workspace scan for performance
71        if cleanup_previous {
72            self.cleanup_definitions_for_file(&file_path);
73        }
74
75        // Check if this is a conftest.py
76        let is_conftest = file_path
77            .file_name()
78            .map(|n| n == "conftest.py")
79            .unwrap_or(false);
80        debug!("is_conftest: {}", is_conftest);
81
82        // Get or build line index for O(1) line lookups (cached for performance)
83        let line_index = self.get_line_index(&file_path, content);
84
85        // Process each statement in the module
86        if let rustpython_parser::ast::Mod::Module(module) = parsed {
87            debug!("Module has {} statements", module.body.len());
88
89            // First pass: collect all module-level names (imports, assignments, function/class defs)
90            let mut module_level_names = HashSet::new();
91            for stmt in &module.body {
92                self.collect_module_level_names(stmt, &mut module_level_names);
93            }
94            self.imports.insert(file_path.clone(), module_level_names);
95
96            // Second pass: analyze fixtures and tests
97            for stmt in &module.body {
98                self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
99            }
100        }
101
102        debug!("Analysis complete for {:?}", file_path);
103
104        // Periodically evict cache entries to prevent unbounded memory growth
105        self.evict_cache_if_needed();
106    }
107
108    /// Remove definitions that were in a specific file.
109    /// Uses the file_definitions reverse index for efficient O(m) cleanup
110    /// where m = number of fixtures in this file, rather than O(n) where
111    /// n = total number of unique fixture names.
112    ///
113    /// Deadlock-free design:
114    /// 1. Atomically remove the set of fixture names from file_definitions
115    /// 2. For each fixture name, get a mutable reference, modify, then drop
116    /// 3. Only after dropping the reference, remove empty entries
117    fn cleanup_definitions_for_file(&self, file_path: &PathBuf) {
118        // Step 1: Atomically remove and get the fixture names for this file
119        let fixture_names = match self.file_definitions.remove(file_path) {
120            Some((_, names)) => names,
121            None => return, // No fixtures defined in this file
122        };
123
124        // Step 2: For each fixture name, remove definitions from this file
125        for fixture_name in fixture_names {
126            let should_remove = {
127                // Get mutable reference, modify in place, check if empty
128                if let Some(mut defs) = self.definitions.get_mut(&fixture_name) {
129                    defs.retain(|def| def.file_path != *file_path);
130                    defs.is_empty()
131                } else {
132                    false
133                }
134            }; // RefMut dropped here - safe to call remove_if now
135
136            // Step 3: Remove empty entries atomically
137            if should_remove {
138                // Use remove_if to ensure we only remove if still empty
139                // (another thread might have added a definition)
140                self.definitions
141                    .remove_if(&fixture_name, |_, defs| defs.is_empty());
142            }
143        }
144    }
145
146    /// Remove usages from the usage_by_fixture reverse index for a specific file.
147    /// Called before re-analyzing a file to clean up stale entries.
148    ///
149    /// Collects all keys first (without filtering) to avoid holding read locks
150    /// while doing the filter check, which could cause deadlocks.
151    fn cleanup_usages_for_file(&self, file_path: &PathBuf) {
152        // Collect all keys first to avoid holding any locks during iteration
153        let all_keys: Vec<String> = self
154            .usage_by_fixture
155            .iter()
156            .map(|entry| entry.key().clone())
157            .collect();
158
159        // Process each key - check if it has usages from this file and clean up
160        for fixture_name in all_keys {
161            let should_remove = {
162                if let Some(mut usages) = self.usage_by_fixture.get_mut(&fixture_name) {
163                    let had_usages = usages.iter().any(|(path, _)| path == file_path);
164                    if had_usages {
165                        usages.retain(|(path, _)| path != file_path);
166                    }
167                    usages.is_empty()
168                } else {
169                    false
170                }
171            };
172
173            if should_remove {
174                self.usage_by_fixture
175                    .remove_if(&fixture_name, |_, usages| usages.is_empty());
176            }
177        }
178    }
179
180    /// Build an index of line start offsets for O(1) line number lookups.
181    /// Uses memchr for SIMD-accelerated newline searching.
182    pub(crate) fn build_line_index(content: &str) -> Vec<usize> {
183        let bytes = content.as_bytes();
184        let mut line_index = Vec::with_capacity(content.len() / 30);
185        line_index.push(0);
186        for i in memchr::memchr_iter(b'\n', bytes) {
187            line_index.push(i + 1);
188        }
189        line_index
190    }
191
192    /// Get line number (1-based) from byte offset
193    pub(crate) fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
194        match line_index.binary_search(&offset) {
195            Ok(line) => line + 1,
196            Err(line) => line,
197        }
198    }
199
200    /// Get character position within a line from byte offset
201    pub(crate) fn get_char_position_from_offset(
202        &self,
203        offset: usize,
204        line_index: &[usize],
205    ) -> usize {
206        let line = self.get_line_from_offset(offset, line_index);
207        let line_start = line_index[line - 1];
208        offset.saturating_sub(line_start)
209    }
210
211    /// Returns an iterator over all function arguments including positional-only,
212    /// regular positional, and keyword-only arguments.
213    /// This is needed because pytest fixtures can be declared as any of these types.
214    pub(crate) fn all_args(args: &Arguments) -> impl Iterator<Item = &ArgWithDefault> {
215        args.posonlyargs
216            .iter()
217            .chain(args.args.iter())
218            .chain(args.kwonlyargs.iter())
219    }
220
221    /// Helper to record a fixture usage in the database.
222    /// Reduces code duplication across multiple call sites.
223    /// Also maintains usage_by_fixture reverse index for efficient reference lookups.
224    fn record_fixture_usage(
225        &self,
226        file_path: &Path,
227        fixture_name: String,
228        line: usize,
229        start_char: usize,
230        end_char: usize,
231    ) {
232        let file_path_buf = file_path.to_path_buf();
233        let usage = FixtureUsage {
234            name: fixture_name.clone(),
235            file_path: file_path_buf.clone(),
236            line,
237            start_char,
238            end_char,
239        };
240
241        // Add to per-file usages map
242        self.usages
243            .entry(file_path_buf.clone())
244            .or_default()
245            .push(usage.clone());
246
247        // Add to reverse index for efficient reference lookups
248        self.usage_by_fixture
249            .entry(fixture_name)
250            .or_default()
251            .push((file_path_buf, usage));
252    }
253
254    /// Helper to record a fixture definition in the database.
255    /// Also maintains the file_definitions reverse index for efficient cleanup.
256    fn record_fixture_definition(&self, definition: FixtureDefinition) {
257        let file_path = definition.file_path.clone();
258        let fixture_name = definition.name.clone();
259
260        // Add to main definitions map
261        self.definitions
262            .entry(fixture_name.clone())
263            .or_default()
264            .push(definition);
265
266        // Maintain reverse index for efficient cleanup
267        self.file_definitions
268            .entry(file_path)
269            .or_default()
270            .insert(fixture_name);
271
272        // Invalidate cycle cache since definitions changed
273        self.invalidate_cycle_cache();
274    }
275
276    /// Visit a statement and extract fixture definitions and usages
277    fn visit_stmt(
278        &self,
279        stmt: &Stmt,
280        file_path: &PathBuf,
281        _is_conftest: bool,
282        content: &str,
283        line_index: &[usize],
284    ) {
285        // First check for assignment-style fixtures: fixture_name = pytest.fixture()(func)
286        if let Stmt::Assign(assign) = stmt {
287            self.visit_assignment_fixture(assign, file_path, content, line_index);
288
289            // Check for pytestmark = pytest.mark.usefixtures(...) or
290            // pytestmark = [pytest.mark.usefixtures(...), ...]
291            let is_pytestmark = assign.targets.iter().any(
292                |target| matches!(target, Expr::Name(name) if name.id.as_str() == "pytestmark"),
293            );
294            if is_pytestmark {
295                self.visit_pytestmark_assignment(Some(&assign.value), file_path, line_index);
296            }
297        }
298
299        // Check for annotated pytestmark: pytestmark: T = pytest.mark.usefixtures(...)
300        if let Stmt::AnnAssign(ann_assign) = stmt {
301            let is_pytestmark = matches!(
302                ann_assign.target.as_ref(),
303                Expr::Name(name) if name.id.as_str() == "pytestmark"
304            );
305            if is_pytestmark {
306                self.visit_pytestmark_assignment(
307                    ann_assign.value.as_deref(),
308                    file_path,
309                    line_index,
310                );
311            }
312        }
313
314        // Handle class definitions - recurse into class body to find test methods
315        if let Stmt::ClassDef(class_def) = stmt {
316            // Check for @pytest.mark.usefixtures decorator on the class
317            for decorator in &class_def.decorator_list {
318                let usefixtures = decorators::extract_usefixtures_names(decorator);
319                for (fixture_name, range) in usefixtures {
320                    let usage_line =
321                        self.get_line_from_offset(range.start().to_usize(), line_index);
322                    let start_char =
323                        self.get_char_position_from_offset(range.start().to_usize(), line_index);
324                    let end_char =
325                        self.get_char_position_from_offset(range.end().to_usize(), line_index);
326
327                    info!(
328                        "Found usefixtures usage on class: {} at {:?}:{}:{}",
329                        fixture_name, file_path, usage_line, start_char
330                    );
331
332                    self.record_fixture_usage(
333                        file_path,
334                        fixture_name,
335                        usage_line,
336                        start_char + 1,
337                        end_char - 1,
338                    );
339                }
340            }
341
342            for class_stmt in &class_def.body {
343                self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
344            }
345            return;
346        }
347
348        // Handle both regular and async function definitions
349        let (func_name, decorator_list, args, range, body, returns) = match stmt {
350            Stmt::FunctionDef(func_def) => (
351                func_def.name.as_str(),
352                &func_def.decorator_list,
353                &func_def.args,
354                func_def.range,
355                &func_def.body,
356                &func_def.returns,
357            ),
358            Stmt::AsyncFunctionDef(func_def) => (
359                func_def.name.as_str(),
360                &func_def.decorator_list,
361                &func_def.args,
362                func_def.range,
363                &func_def.body,
364                &func_def.returns,
365            ),
366            _ => return,
367        };
368
369        debug!("Found function: {}", func_name);
370
371        // Check for @pytest.mark.usefixtures decorator on the function
372        for decorator in decorator_list {
373            let usefixtures = decorators::extract_usefixtures_names(decorator);
374            for (fixture_name, range) in usefixtures {
375                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
376                let start_char =
377                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
378                let end_char =
379                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
380
381                info!(
382                    "Found usefixtures usage on function: {} at {:?}:{}:{}",
383                    fixture_name, file_path, usage_line, start_char
384                );
385
386                self.record_fixture_usage(
387                    file_path,
388                    fixture_name,
389                    usage_line,
390                    start_char + 1,
391                    end_char - 1,
392                );
393            }
394        }
395
396        // Check for @pytest.mark.parametrize with indirect=True on the function
397        for decorator in decorator_list {
398            let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
399            for (fixture_name, range) in indirect_fixtures {
400                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
401                let start_char =
402                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
403                let end_char =
404                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
405
406                info!(
407                    "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
408                    fixture_name, file_path, usage_line, start_char
409                );
410
411                self.record_fixture_usage(
412                    file_path,
413                    fixture_name,
414                    usage_line,
415                    start_char + 1,
416                    end_char - 1,
417                );
418            }
419        }
420
421        // Check if this is a fixture definition
422        debug!(
423            "Function {} has {} decorators",
424            func_name,
425            decorator_list.len()
426        );
427        let fixture_decorator = decorator_list
428            .iter()
429            .find(|dec| decorators::is_fixture_decorator(dec));
430
431        if let Some(decorator) = fixture_decorator {
432            debug!("  Decorator matched as fixture!");
433
434            // Check if the fixture has a custom name
435            let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
436                .unwrap_or_else(|| func_name.to_string());
437
438            // Extract scope from decorator (defaults to function scope)
439            let scope = decorators::extract_fixture_scope(decorator).unwrap_or_default();
440            let autouse = decorators::extract_fixture_autouse(decorator);
441
442            let line = self.get_line_from_offset(range.start().to_usize(), line_index);
443            let docstring = self.extract_docstring(body);
444            let return_type = self.extract_return_type(returns, body, content);
445
446            info!(
447                "Found fixture definition: {} (function: {}, scope: {:?}) at {:?}:{}",
448                fixture_name, func_name, scope, file_path, line
449            );
450
451            let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
452
453            let is_third_party = file_path.to_string_lossy().contains("site-packages")
454                || self.is_editable_install_third_party(file_path);
455            let is_plugin = self.plugin_fixture_files.contains_key(file_path);
456
457            // Fixtures can depend on other fixtures - collect dependencies first
458            let mut declared_params: HashSet<String> = HashSet::new();
459            let mut dependencies: Vec<String> = Vec::new();
460            declared_params.insert("self".to_string());
461            declared_params.insert("request".to_string());
462            declared_params.insert(func_name.to_string());
463
464            for arg in Self::all_args(args) {
465                let arg_name = arg.def.arg.as_str();
466                declared_params.insert(arg_name.to_string());
467                // Track as dependency if it's not self/request (these are special)
468                if arg_name != "self" && arg_name != "request" {
469                    dependencies.push(arg_name.to_string());
470                }
471            }
472
473            // Calculate end line from the function's range
474            let end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
475
476            let definition = FixtureDefinition {
477                name: fixture_name.clone(),
478                file_path: file_path.clone(),
479                line,
480                end_line,
481                start_char,
482                end_char,
483                docstring,
484                return_type,
485                is_third_party,
486                is_plugin,
487                dependencies: dependencies.clone(),
488                scope,
489                yield_line: self.find_yield_line(body, line_index),
490                autouse,
491            };
492
493            self.record_fixture_definition(definition);
494
495            // Record each dependency as a usage
496            for arg in Self::all_args(args) {
497                let arg_name = arg.def.arg.as_str();
498
499                if arg_name != "self" && arg_name != "request" {
500                    let arg_line =
501                        self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
502                    let start_char = self.get_char_position_from_offset(
503                        arg.def.range.start().to_usize(),
504                        line_index,
505                    );
506                    // Use parameter name length, not AST range (which includes type annotation)
507                    let end_char = start_char + arg_name.len();
508
509                    info!(
510                        "Found fixture dependency: {} at {:?}:{}:{}",
511                        arg_name, file_path, arg_line, start_char
512                    );
513
514                    self.record_fixture_usage(
515                        file_path,
516                        arg_name.to_string(),
517                        arg_line,
518                        start_char,
519                        end_char,
520                    );
521                }
522            }
523
524            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
525            self.scan_function_body_for_undeclared_fixtures(
526                body,
527                file_path,
528                line_index,
529                &declared_params,
530                func_name,
531                function_line,
532            );
533        }
534
535        // Check if this is a test function
536        let is_test = func_name.starts_with("test_");
537
538        if is_test {
539            debug!("Found test function: {}", func_name);
540
541            let mut declared_params: HashSet<String> = HashSet::new();
542            declared_params.insert("self".to_string());
543            declared_params.insert("request".to_string());
544
545            for arg in Self::all_args(args) {
546                let arg_name = arg.def.arg.as_str();
547                declared_params.insert(arg_name.to_string());
548
549                if arg_name != "self" {
550                    let arg_offset = arg.def.range.start().to_usize();
551                    let arg_line = self.get_line_from_offset(arg_offset, line_index);
552                    let start_char = self.get_char_position_from_offset(arg_offset, line_index);
553                    // Use parameter name length, not AST range (which includes type annotation)
554                    let end_char = start_char + arg_name.len();
555
556                    debug!(
557                        "Parameter {} at offset {}, calculated line {}, char {}",
558                        arg_name, arg_offset, arg_line, start_char
559                    );
560                    info!(
561                        "Found fixture usage: {} at {:?}:{}:{}",
562                        arg_name, file_path, arg_line, start_char
563                    );
564
565                    self.record_fixture_usage(
566                        file_path,
567                        arg_name.to_string(),
568                        arg_line,
569                        start_char,
570                        end_char,
571                    );
572                }
573            }
574
575            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
576            self.scan_function_body_for_undeclared_fixtures(
577                body,
578                file_path,
579                line_index,
580                &declared_params,
581                func_name,
582                function_line,
583            );
584        }
585    }
586
587    /// Handle assignment-style fixtures: fixture_name = pytest.fixture()(func)
588    fn visit_assignment_fixture(
589        &self,
590        assign: &rustpython_parser::ast::StmtAssign,
591        file_path: &PathBuf,
592        _content: &str,
593        line_index: &[usize],
594    ) {
595        if let Expr::Call(outer_call) = &*assign.value {
596            if let Expr::Call(inner_call) = &*outer_call.func {
597                if decorators::is_fixture_decorator(&inner_call.func) {
598                    for target in &assign.targets {
599                        if let Expr::Name(name) = target {
600                            let fixture_name = name.id.as_str();
601                            let line = self
602                                .get_line_from_offset(assign.range.start().to_usize(), line_index);
603
604                            let start_char = self.get_char_position_from_offset(
605                                name.range.start().to_usize(),
606                                line_index,
607                            );
608                            let end_char = self.get_char_position_from_offset(
609                                name.range.end().to_usize(),
610                                line_index,
611                            );
612
613                            info!(
614                                "Found fixture assignment: {} at {:?}:{}:{}-{}",
615                                fixture_name, file_path, line, start_char, end_char
616                            );
617
618                            let is_third_party =
619                                file_path.to_string_lossy().contains("site-packages")
620                                    || self.is_editable_install_third_party(file_path);
621                            let is_plugin = self.plugin_fixture_files.contains_key(file_path);
622                            let definition = FixtureDefinition {
623                                name: fixture_name.to_string(),
624                                file_path: file_path.clone(),
625                                line,
626                                end_line: line, // Assignment-style fixtures are single-line
627                                start_char,
628                                end_char,
629                                docstring: None,
630                                return_type: None,
631                                is_third_party,
632                                is_plugin,
633                                dependencies: Vec::new(), // Assignment-style fixtures don't have explicit dependencies
634                                scope: FixtureScope::default(), // Assignment-style fixtures default to function scope
635                                yield_line: None, // Assignment-style fixtures don't have yield statements
636                                autouse: false,   // Assignment-style fixtures are never autouse
637                            };
638
639                            self.record_fixture_definition(definition);
640                        }
641                    }
642                }
643            }
644        }
645    }
646
647    /// Handle pytestmark usefixtures — covers both plain and annotated assignments:
648    ///   pytestmark = pytest.mark.usefixtures("fix1", "fix2")
649    ///   pytestmark = [pytest.mark.usefixtures("fix1"), pytest.mark.skip]
650    ///   pytestmark = (pytest.mark.usefixtures("fix1"), pytest.mark.usefixtures("fix2"))
651    ///   pytestmark: list[MarkDecorator] = [pytest.mark.usefixtures("fix1"), ...]
652    ///
653    /// `value` is `None` for bare annotated assignments (`pytestmark: T`) which are a no-op.
654    fn visit_pytestmark_assignment(
655        &self,
656        value: Option<&Expr>,
657        file_path: &PathBuf,
658        line_index: &[usize],
659    ) {
660        let Some(value) = value else {
661            return;
662        };
663
664        let usefixtures = decorators::extract_usefixtures_from_expr(value);
665        for (fixture_name, range) in usefixtures {
666            let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
667            let start_char =
668                self.get_char_position_from_offset(range.start().to_usize(), line_index);
669            let end_char = self.get_char_position_from_offset(range.end().to_usize(), line_index);
670
671            info!(
672                "Found usefixtures usage via pytestmark assignment: {} at {:?}:{}:{}",
673                fixture_name, file_path, usage_line, start_char
674            );
675
676            self.record_fixture_usage(
677                file_path,
678                fixture_name,
679                usage_line,
680                start_char.saturating_add(1),
681                end_char.saturating_sub(1),
682            );
683        }
684    }
685}
686
687// Second impl block for additional analyzer methods
688impl FixtureDatabase {
689    // ============ Module-level name collection ============
690
691    /// Collect all module-level names (imports, assignments, function/class defs)
692    fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
693        match stmt {
694            Stmt::Import(import_stmt) => {
695                for alias in &import_stmt.names {
696                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
697                    names.insert(name.to_string());
698                }
699            }
700            Stmt::ImportFrom(import_from) => {
701                for alias in &import_from.names {
702                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
703                    names.insert(name.to_string());
704                }
705            }
706            Stmt::FunctionDef(func_def) => {
707                let is_fixture = func_def
708                    .decorator_list
709                    .iter()
710                    .any(decorators::is_fixture_decorator);
711                if !is_fixture {
712                    names.insert(func_def.name.to_string());
713                }
714            }
715            Stmt::AsyncFunctionDef(func_def) => {
716                let is_fixture = func_def
717                    .decorator_list
718                    .iter()
719                    .any(decorators::is_fixture_decorator);
720                if !is_fixture {
721                    names.insert(func_def.name.to_string());
722                }
723            }
724            Stmt::ClassDef(class_def) => {
725                names.insert(class_def.name.to_string());
726            }
727            Stmt::Assign(assign) => {
728                for target in &assign.targets {
729                    self.collect_names_from_expr(target, names);
730                }
731            }
732            Stmt::AnnAssign(ann_assign) => {
733                self.collect_names_from_expr(&ann_assign.target, names);
734            }
735            _ => {}
736        }
737    }
738
739    #[allow(clippy::only_used_in_recursion)]
740    pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
741        match expr {
742            Expr::Name(name) => {
743                names.insert(name.id.to_string());
744            }
745            Expr::Tuple(tuple) => {
746                for elt in &tuple.elts {
747                    self.collect_names_from_expr(elt, names);
748                }
749            }
750            Expr::List(list) => {
751                for elt in &list.elts {
752                    self.collect_names_from_expr(elt, names);
753                }
754            }
755            _ => {}
756        }
757    }
758
759    // Docstring and return type extraction methods are in docstring.rs
760
761    /// Find the character position of a function name in a line
762    fn find_function_name_position(
763        &self,
764        content: &str,
765        line: usize,
766        func_name: &str,
767    ) -> (usize, usize) {
768        super::string_utils::find_function_name_position(content, line, func_name)
769    }
770
771    /// Find the line number of the first yield statement in a function body.
772    /// Returns None if no yield statement is found.
773    fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
774        for stmt in body {
775            if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
776                return Some(line);
777            }
778        }
779        None
780    }
781
782    /// Recursively search for yield statements in a statement.
783    fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
784        match stmt {
785            Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
786            Stmt::If(if_stmt) => {
787                // Check body
788                for s in &if_stmt.body {
789                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
790                        return Some(line);
791                    }
792                }
793                // Check elif/else
794                for s in &if_stmt.orelse {
795                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
796                        return Some(line);
797                    }
798                }
799                None
800            }
801            Stmt::With(with_stmt) => {
802                for s in &with_stmt.body {
803                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
804                        return Some(line);
805                    }
806                }
807                None
808            }
809            Stmt::AsyncWith(with_stmt) => {
810                for s in &with_stmt.body {
811                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
812                        return Some(line);
813                    }
814                }
815                None
816            }
817            Stmt::Try(try_stmt) => {
818                for s in &try_stmt.body {
819                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
820                        return Some(line);
821                    }
822                }
823                for handler in &try_stmt.handlers {
824                    let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
825                    for s in &h.body {
826                        if let Some(line) = self.find_yield_in_stmt(s, line_index) {
827                            return Some(line);
828                        }
829                    }
830                }
831                for s in &try_stmt.orelse {
832                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
833                        return Some(line);
834                    }
835                }
836                for s in &try_stmt.finalbody {
837                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
838                        return Some(line);
839                    }
840                }
841                None
842            }
843            Stmt::For(for_stmt) => {
844                for s in &for_stmt.body {
845                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
846                        return Some(line);
847                    }
848                }
849                for s in &for_stmt.orelse {
850                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
851                        return Some(line);
852                    }
853                }
854                None
855            }
856            Stmt::AsyncFor(for_stmt) => {
857                for s in &for_stmt.body {
858                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
859                        return Some(line);
860                    }
861                }
862                for s in &for_stmt.orelse {
863                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
864                        return Some(line);
865                    }
866                }
867                None
868            }
869            Stmt::While(while_stmt) => {
870                for s in &while_stmt.body {
871                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
872                        return Some(line);
873                    }
874                }
875                for s in &while_stmt.orelse {
876                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
877                        return Some(line);
878                    }
879                }
880                None
881            }
882            _ => None,
883        }
884    }
885
886    /// Find yield expression and return its line number.
887    fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
888        match expr {
889            Expr::Yield(yield_expr) => {
890                let line =
891                    self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
892                Some(line)
893            }
894            Expr::YieldFrom(yield_from) => {
895                let line =
896                    self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
897                Some(line)
898            }
899            _ => None,
900        }
901    }
902}
903
904// Undeclared fixtures scanning methods are in undeclared.rs