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
290        // Handle class definitions - recurse into class body to find test methods
291        if let Stmt::ClassDef(class_def) = stmt {
292            // Check for @pytest.mark.usefixtures decorator on the class
293            for decorator in &class_def.decorator_list {
294                let usefixtures = decorators::extract_usefixtures_names(decorator);
295                for (fixture_name, range) in usefixtures {
296                    let usage_line =
297                        self.get_line_from_offset(range.start().to_usize(), line_index);
298                    let start_char =
299                        self.get_char_position_from_offset(range.start().to_usize(), line_index);
300                    let end_char =
301                        self.get_char_position_from_offset(range.end().to_usize(), line_index);
302
303                    info!(
304                        "Found usefixtures usage on class: {} at {:?}:{}:{}",
305                        fixture_name, file_path, usage_line, start_char
306                    );
307
308                    self.record_fixture_usage(
309                        file_path,
310                        fixture_name,
311                        usage_line,
312                        start_char + 1,
313                        end_char - 1,
314                    );
315                }
316            }
317
318            for class_stmt in &class_def.body {
319                self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
320            }
321            return;
322        }
323
324        // Handle both regular and async function definitions
325        let (func_name, decorator_list, args, range, body, returns) = match stmt {
326            Stmt::FunctionDef(func_def) => (
327                func_def.name.as_str(),
328                &func_def.decorator_list,
329                &func_def.args,
330                func_def.range,
331                &func_def.body,
332                &func_def.returns,
333            ),
334            Stmt::AsyncFunctionDef(func_def) => (
335                func_def.name.as_str(),
336                &func_def.decorator_list,
337                &func_def.args,
338                func_def.range,
339                &func_def.body,
340                &func_def.returns,
341            ),
342            _ => return,
343        };
344
345        debug!("Found function: {}", func_name);
346
347        // Check for @pytest.mark.usefixtures decorator on the function
348        for decorator in decorator_list {
349            let usefixtures = decorators::extract_usefixtures_names(decorator);
350            for (fixture_name, range) in usefixtures {
351                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
352                let start_char =
353                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
354                let end_char =
355                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
356
357                info!(
358                    "Found usefixtures usage on function: {} at {:?}:{}:{}",
359                    fixture_name, file_path, usage_line, start_char
360                );
361
362                self.record_fixture_usage(
363                    file_path,
364                    fixture_name,
365                    usage_line,
366                    start_char + 1,
367                    end_char - 1,
368                );
369            }
370        }
371
372        // Check for @pytest.mark.parametrize with indirect=True on the function
373        for decorator in decorator_list {
374            let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
375            for (fixture_name, range) in indirect_fixtures {
376                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
377                let start_char =
378                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
379                let end_char =
380                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
381
382                info!(
383                    "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
384                    fixture_name, file_path, usage_line, start_char
385                );
386
387                self.record_fixture_usage(
388                    file_path,
389                    fixture_name,
390                    usage_line,
391                    start_char + 1,
392                    end_char - 1,
393                );
394            }
395        }
396
397        // Check if this is a fixture definition
398        debug!(
399            "Function {} has {} decorators",
400            func_name,
401            decorator_list.len()
402        );
403        let fixture_decorator = decorator_list
404            .iter()
405            .find(|dec| decorators::is_fixture_decorator(dec));
406
407        if let Some(decorator) = fixture_decorator {
408            debug!("  Decorator matched as fixture!");
409
410            // Check if the fixture has a custom name
411            let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
412                .unwrap_or_else(|| func_name.to_string());
413
414            // Extract scope from decorator (defaults to function scope)
415            let scope = decorators::extract_fixture_scope(decorator).unwrap_or_default();
416
417            let line = self.get_line_from_offset(range.start().to_usize(), line_index);
418            let docstring = self.extract_docstring(body);
419            let return_type = self.extract_return_type(returns, body, content);
420
421            info!(
422                "Found fixture definition: {} (function: {}, scope: {:?}) at {:?}:{}",
423                fixture_name, func_name, scope, file_path, line
424            );
425
426            let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
427
428            let is_third_party = file_path.to_string_lossy().contains("site-packages")
429                || self.is_editable_install_third_party(file_path);
430
431            // Fixtures can depend on other fixtures - collect dependencies first
432            let mut declared_params: HashSet<String> = HashSet::new();
433            let mut dependencies: Vec<String> = Vec::new();
434            declared_params.insert("self".to_string());
435            declared_params.insert("request".to_string());
436            declared_params.insert(func_name.to_string());
437
438            for arg in Self::all_args(args) {
439                let arg_name = arg.def.arg.as_str();
440                declared_params.insert(arg_name.to_string());
441                // Track as dependency if it's not self/request (these are special)
442                if arg_name != "self" && arg_name != "request" {
443                    dependencies.push(arg_name.to_string());
444                }
445            }
446
447            // Calculate end line from the function's range
448            let end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
449
450            let definition = FixtureDefinition {
451                name: fixture_name.clone(),
452                file_path: file_path.clone(),
453                line,
454                end_line,
455                start_char,
456                end_char,
457                docstring,
458                return_type,
459                is_third_party,
460                dependencies: dependencies.clone(),
461                scope,
462                yield_line: self.find_yield_line(body, line_index),
463            };
464
465            self.record_fixture_definition(definition);
466
467            // Record each dependency as a usage
468            for arg in Self::all_args(args) {
469                let arg_name = arg.def.arg.as_str();
470
471                if arg_name != "self" && arg_name != "request" {
472                    let arg_line =
473                        self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
474                    let start_char = self.get_char_position_from_offset(
475                        arg.def.range.start().to_usize(),
476                        line_index,
477                    );
478                    // Use parameter name length, not AST range (which includes type annotation)
479                    let end_char = start_char + arg_name.len();
480
481                    info!(
482                        "Found fixture dependency: {} at {:?}:{}:{}",
483                        arg_name, file_path, arg_line, start_char
484                    );
485
486                    self.record_fixture_usage(
487                        file_path,
488                        arg_name.to_string(),
489                        arg_line,
490                        start_char,
491                        end_char,
492                    );
493                }
494            }
495
496            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
497            self.scan_function_body_for_undeclared_fixtures(
498                body,
499                file_path,
500                line_index,
501                &declared_params,
502                func_name,
503                function_line,
504            );
505        }
506
507        // Check if this is a test function
508        let is_test = func_name.starts_with("test_");
509
510        if is_test {
511            debug!("Found test function: {}", func_name);
512
513            let mut declared_params: HashSet<String> = HashSet::new();
514            declared_params.insert("self".to_string());
515            declared_params.insert("request".to_string());
516
517            for arg in Self::all_args(args) {
518                let arg_name = arg.def.arg.as_str();
519                declared_params.insert(arg_name.to_string());
520
521                if arg_name != "self" {
522                    let arg_offset = arg.def.range.start().to_usize();
523                    let arg_line = self.get_line_from_offset(arg_offset, line_index);
524                    let start_char = self.get_char_position_from_offset(arg_offset, line_index);
525                    // Use parameter name length, not AST range (which includes type annotation)
526                    let end_char = start_char + arg_name.len();
527
528                    debug!(
529                        "Parameter {} at offset {}, calculated line {}, char {}",
530                        arg_name, arg_offset, arg_line, start_char
531                    );
532                    info!(
533                        "Found fixture usage: {} at {:?}:{}:{}",
534                        arg_name, file_path, arg_line, start_char
535                    );
536
537                    self.record_fixture_usage(
538                        file_path,
539                        arg_name.to_string(),
540                        arg_line,
541                        start_char,
542                        end_char,
543                    );
544                }
545            }
546
547            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
548            self.scan_function_body_for_undeclared_fixtures(
549                body,
550                file_path,
551                line_index,
552                &declared_params,
553                func_name,
554                function_line,
555            );
556        }
557    }
558
559    /// Handle assignment-style fixtures: fixture_name = pytest.fixture()(func)
560    fn visit_assignment_fixture(
561        &self,
562        assign: &rustpython_parser::ast::StmtAssign,
563        file_path: &PathBuf,
564        _content: &str,
565        line_index: &[usize],
566    ) {
567        if let Expr::Call(outer_call) = &*assign.value {
568            if let Expr::Call(inner_call) = &*outer_call.func {
569                if decorators::is_fixture_decorator(&inner_call.func) {
570                    for target in &assign.targets {
571                        if let Expr::Name(name) = target {
572                            let fixture_name = name.id.as_str();
573                            let line = self
574                                .get_line_from_offset(assign.range.start().to_usize(), line_index);
575
576                            let start_char = self.get_char_position_from_offset(
577                                name.range.start().to_usize(),
578                                line_index,
579                            );
580                            let end_char = self.get_char_position_from_offset(
581                                name.range.end().to_usize(),
582                                line_index,
583                            );
584
585                            info!(
586                                "Found fixture assignment: {} at {:?}:{}:{}-{}",
587                                fixture_name, file_path, line, start_char, end_char
588                            );
589
590                            let is_third_party =
591                                file_path.to_string_lossy().contains("site-packages");
592                            let definition = FixtureDefinition {
593                                name: fixture_name.to_string(),
594                                file_path: file_path.clone(),
595                                line,
596                                end_line: line, // Assignment-style fixtures are single-line
597                                start_char,
598                                end_char,
599                                docstring: None,
600                                return_type: None,
601                                is_third_party,
602                                dependencies: Vec::new(), // Assignment-style fixtures don't have explicit dependencies
603                                scope: FixtureScope::default(), // Assignment-style fixtures default to function scope
604                                yield_line: None, // Assignment-style fixtures don't have yield statements
605                            };
606
607                            self.record_fixture_definition(definition);
608                        }
609                    }
610                }
611            }
612        }
613    }
614}
615
616// Second impl block for additional analyzer methods
617impl FixtureDatabase {
618    // ============ Module-level name collection ============
619
620    /// Collect all module-level names (imports, assignments, function/class defs)
621    fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
622        match stmt {
623            Stmt::Import(import_stmt) => {
624                for alias in &import_stmt.names {
625                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
626                    names.insert(name.to_string());
627                }
628            }
629            Stmt::ImportFrom(import_from) => {
630                for alias in &import_from.names {
631                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
632                    names.insert(name.to_string());
633                }
634            }
635            Stmt::FunctionDef(func_def) => {
636                let is_fixture = func_def
637                    .decorator_list
638                    .iter()
639                    .any(decorators::is_fixture_decorator);
640                if !is_fixture {
641                    names.insert(func_def.name.to_string());
642                }
643            }
644            Stmt::AsyncFunctionDef(func_def) => {
645                let is_fixture = func_def
646                    .decorator_list
647                    .iter()
648                    .any(decorators::is_fixture_decorator);
649                if !is_fixture {
650                    names.insert(func_def.name.to_string());
651                }
652            }
653            Stmt::ClassDef(class_def) => {
654                names.insert(class_def.name.to_string());
655            }
656            Stmt::Assign(assign) => {
657                for target in &assign.targets {
658                    self.collect_names_from_expr(target, names);
659                }
660            }
661            Stmt::AnnAssign(ann_assign) => {
662                self.collect_names_from_expr(&ann_assign.target, names);
663            }
664            _ => {}
665        }
666    }
667
668    #[allow(clippy::only_used_in_recursion)]
669    pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
670        match expr {
671            Expr::Name(name) => {
672                names.insert(name.id.to_string());
673            }
674            Expr::Tuple(tuple) => {
675                for elt in &tuple.elts {
676                    self.collect_names_from_expr(elt, names);
677                }
678            }
679            Expr::List(list) => {
680                for elt in &list.elts {
681                    self.collect_names_from_expr(elt, names);
682                }
683            }
684            _ => {}
685        }
686    }
687
688    // Docstring and return type extraction methods are in docstring.rs
689
690    /// Find the character position of a function name in a line
691    fn find_function_name_position(
692        &self,
693        content: &str,
694        line: usize,
695        func_name: &str,
696    ) -> (usize, usize) {
697        super::string_utils::find_function_name_position(content, line, func_name)
698    }
699
700    /// Find the line number of the first yield statement in a function body.
701    /// Returns None if no yield statement is found.
702    fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
703        for stmt in body {
704            if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
705                return Some(line);
706            }
707        }
708        None
709    }
710
711    /// Recursively search for yield statements in a statement.
712    fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
713        match stmt {
714            Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
715            Stmt::If(if_stmt) => {
716                // Check body
717                for s in &if_stmt.body {
718                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
719                        return Some(line);
720                    }
721                }
722                // Check elif/else
723                for s in &if_stmt.orelse {
724                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
725                        return Some(line);
726                    }
727                }
728                None
729            }
730            Stmt::With(with_stmt) => {
731                for s in &with_stmt.body {
732                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
733                        return Some(line);
734                    }
735                }
736                None
737            }
738            Stmt::AsyncWith(with_stmt) => {
739                for s in &with_stmt.body {
740                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
741                        return Some(line);
742                    }
743                }
744                None
745            }
746            Stmt::Try(try_stmt) => {
747                for s in &try_stmt.body {
748                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
749                        return Some(line);
750                    }
751                }
752                for handler in &try_stmt.handlers {
753                    let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
754                    for s in &h.body {
755                        if let Some(line) = self.find_yield_in_stmt(s, line_index) {
756                            return Some(line);
757                        }
758                    }
759                }
760                for s in &try_stmt.orelse {
761                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
762                        return Some(line);
763                    }
764                }
765                for s in &try_stmt.finalbody {
766                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
767                        return Some(line);
768                    }
769                }
770                None
771            }
772            Stmt::For(for_stmt) => {
773                for s in &for_stmt.body {
774                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
775                        return Some(line);
776                    }
777                }
778                for s in &for_stmt.orelse {
779                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
780                        return Some(line);
781                    }
782                }
783                None
784            }
785            Stmt::AsyncFor(for_stmt) => {
786                for s in &for_stmt.body {
787                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
788                        return Some(line);
789                    }
790                }
791                for s in &for_stmt.orelse {
792                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
793                        return Some(line);
794                    }
795                }
796                None
797            }
798            Stmt::While(while_stmt) => {
799                for s in &while_stmt.body {
800                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
801                        return Some(line);
802                    }
803                }
804                for s in &while_stmt.orelse {
805                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
806                        return Some(line);
807                    }
808                }
809                None
810            }
811            _ => None,
812        }
813    }
814
815    /// Find yield expression and return its line number.
816    fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
817        match expr {
818            Expr::Yield(yield_expr) => {
819                let line =
820                    self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
821                Some(line)
822            }
823            Expr::YieldFrom(yield_from) => {
824                let line =
825                    self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
826                Some(line)
827            }
828            _ => None,
829        }
830    }
831}
832
833// Undeclared fixtures scanning methods are in undeclared.rs