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
430            // Fixtures can depend on other fixtures - collect dependencies first
431            let mut declared_params: HashSet<String> = HashSet::new();
432            let mut dependencies: Vec<String> = Vec::new();
433            declared_params.insert("self".to_string());
434            declared_params.insert("request".to_string());
435            declared_params.insert(func_name.to_string());
436
437            for arg in Self::all_args(args) {
438                let arg_name = arg.def.arg.as_str();
439                declared_params.insert(arg_name.to_string());
440                // Track as dependency if it's not self/request (these are special)
441                if arg_name != "self" && arg_name != "request" {
442                    dependencies.push(arg_name.to_string());
443                }
444            }
445
446            // Calculate end line from the function's range
447            let end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
448
449            let definition = FixtureDefinition {
450                name: fixture_name.clone(),
451                file_path: file_path.clone(),
452                line,
453                end_line,
454                start_char,
455                end_char,
456                docstring,
457                return_type,
458                is_third_party,
459                dependencies: dependencies.clone(),
460                scope,
461                yield_line: self.find_yield_line(body, line_index),
462            };
463
464            self.record_fixture_definition(definition);
465
466            // Record each dependency as a usage
467            for arg in Self::all_args(args) {
468                let arg_name = arg.def.arg.as_str();
469
470                if arg_name != "self" && arg_name != "request" {
471                    let arg_line =
472                        self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
473                    let start_char = self.get_char_position_from_offset(
474                        arg.def.range.start().to_usize(),
475                        line_index,
476                    );
477                    // Use parameter name length, not AST range (which includes type annotation)
478                    let end_char = start_char + arg_name.len();
479
480                    info!(
481                        "Found fixture dependency: {} at {:?}:{}:{}",
482                        arg_name, file_path, arg_line, start_char
483                    );
484
485                    self.record_fixture_usage(
486                        file_path,
487                        arg_name.to_string(),
488                        arg_line,
489                        start_char,
490                        end_char,
491                    );
492                }
493            }
494
495            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
496            self.scan_function_body_for_undeclared_fixtures(
497                body,
498                file_path,
499                line_index,
500                &declared_params,
501                func_name,
502                function_line,
503            );
504        }
505
506        // Check if this is a test function
507        let is_test = func_name.starts_with("test_");
508
509        if is_test {
510            debug!("Found test function: {}", func_name);
511
512            let mut declared_params: HashSet<String> = HashSet::new();
513            declared_params.insert("self".to_string());
514            declared_params.insert("request".to_string());
515
516            for arg in Self::all_args(args) {
517                let arg_name = arg.def.arg.as_str();
518                declared_params.insert(arg_name.to_string());
519
520                if arg_name != "self" {
521                    let arg_offset = arg.def.range.start().to_usize();
522                    let arg_line = self.get_line_from_offset(arg_offset, line_index);
523                    let start_char = self.get_char_position_from_offset(arg_offset, line_index);
524                    // Use parameter name length, not AST range (which includes type annotation)
525                    let end_char = start_char + arg_name.len();
526
527                    debug!(
528                        "Parameter {} at offset {}, calculated line {}, char {}",
529                        arg_name, arg_offset, arg_line, start_char
530                    );
531                    info!(
532                        "Found fixture usage: {} at {:?}:{}:{}",
533                        arg_name, file_path, arg_line, start_char
534                    );
535
536                    self.record_fixture_usage(
537                        file_path,
538                        arg_name.to_string(),
539                        arg_line,
540                        start_char,
541                        end_char,
542                    );
543                }
544            }
545
546            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
547            self.scan_function_body_for_undeclared_fixtures(
548                body,
549                file_path,
550                line_index,
551                &declared_params,
552                func_name,
553                function_line,
554            );
555        }
556    }
557
558    /// Handle assignment-style fixtures: fixture_name = pytest.fixture()(func)
559    fn visit_assignment_fixture(
560        &self,
561        assign: &rustpython_parser::ast::StmtAssign,
562        file_path: &PathBuf,
563        _content: &str,
564        line_index: &[usize],
565    ) {
566        if let Expr::Call(outer_call) = &*assign.value {
567            if let Expr::Call(inner_call) = &*outer_call.func {
568                if decorators::is_fixture_decorator(&inner_call.func) {
569                    for target in &assign.targets {
570                        if let Expr::Name(name) = target {
571                            let fixture_name = name.id.as_str();
572                            let line = self
573                                .get_line_from_offset(assign.range.start().to_usize(), line_index);
574
575                            let start_char = self.get_char_position_from_offset(
576                                name.range.start().to_usize(),
577                                line_index,
578                            );
579                            let end_char = self.get_char_position_from_offset(
580                                name.range.end().to_usize(),
581                                line_index,
582                            );
583
584                            info!(
585                                "Found fixture assignment: {} at {:?}:{}:{}-{}",
586                                fixture_name, file_path, line, start_char, end_char
587                            );
588
589                            let is_third_party =
590                                file_path.to_string_lossy().contains("site-packages");
591                            let definition = FixtureDefinition {
592                                name: fixture_name.to_string(),
593                                file_path: file_path.clone(),
594                                line,
595                                end_line: line, // Assignment-style fixtures are single-line
596                                start_char,
597                                end_char,
598                                docstring: None,
599                                return_type: None,
600                                is_third_party,
601                                dependencies: Vec::new(), // Assignment-style fixtures don't have explicit dependencies
602                                scope: FixtureScope::default(), // Assignment-style fixtures default to function scope
603                                yield_line: None, // Assignment-style fixtures don't have yield statements
604                            };
605
606                            self.record_fixture_definition(definition);
607                        }
608                    }
609                }
610            }
611        }
612    }
613}
614
615// Second impl block for additional analyzer methods
616impl FixtureDatabase {
617    // ============ Module-level name collection ============
618
619    /// Collect all module-level names (imports, assignments, function/class defs)
620    fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
621        match stmt {
622            Stmt::Import(import_stmt) => {
623                for alias in &import_stmt.names {
624                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
625                    names.insert(name.to_string());
626                }
627            }
628            Stmt::ImportFrom(import_from) => {
629                for alias in &import_from.names {
630                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
631                    names.insert(name.to_string());
632                }
633            }
634            Stmt::FunctionDef(func_def) => {
635                let is_fixture = func_def
636                    .decorator_list
637                    .iter()
638                    .any(decorators::is_fixture_decorator);
639                if !is_fixture {
640                    names.insert(func_def.name.to_string());
641                }
642            }
643            Stmt::AsyncFunctionDef(func_def) => {
644                let is_fixture = func_def
645                    .decorator_list
646                    .iter()
647                    .any(decorators::is_fixture_decorator);
648                if !is_fixture {
649                    names.insert(func_def.name.to_string());
650                }
651            }
652            Stmt::ClassDef(class_def) => {
653                names.insert(class_def.name.to_string());
654            }
655            Stmt::Assign(assign) => {
656                for target in &assign.targets {
657                    self.collect_names_from_expr(target, names);
658                }
659            }
660            Stmt::AnnAssign(ann_assign) => {
661                self.collect_names_from_expr(&ann_assign.target, names);
662            }
663            _ => {}
664        }
665    }
666
667    #[allow(clippy::only_used_in_recursion)]
668    pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
669        match expr {
670            Expr::Name(name) => {
671                names.insert(name.id.to_string());
672            }
673            Expr::Tuple(tuple) => {
674                for elt in &tuple.elts {
675                    self.collect_names_from_expr(elt, names);
676                }
677            }
678            Expr::List(list) => {
679                for elt in &list.elts {
680                    self.collect_names_from_expr(elt, names);
681                }
682            }
683            _ => {}
684        }
685    }
686
687    // Docstring and return type extraction methods are in docstring.rs
688
689    /// Find the character position of a function name in a line
690    fn find_function_name_position(
691        &self,
692        content: &str,
693        line: usize,
694        func_name: &str,
695    ) -> (usize, usize) {
696        super::string_utils::find_function_name_position(content, line, func_name)
697    }
698
699    /// Find the line number of the first yield statement in a function body.
700    /// Returns None if no yield statement is found.
701    fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
702        for stmt in body {
703            if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
704                return Some(line);
705            }
706        }
707        None
708    }
709
710    /// Recursively search for yield statements in a statement.
711    fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
712        match stmt {
713            Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
714            Stmt::If(if_stmt) => {
715                // Check body
716                for s in &if_stmt.body {
717                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
718                        return Some(line);
719                    }
720                }
721                // Check elif/else
722                for s in &if_stmt.orelse {
723                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
724                        return Some(line);
725                    }
726                }
727                None
728            }
729            Stmt::With(with_stmt) => {
730                for s in &with_stmt.body {
731                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
732                        return Some(line);
733                    }
734                }
735                None
736            }
737            Stmt::AsyncWith(with_stmt) => {
738                for s in &with_stmt.body {
739                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
740                        return Some(line);
741                    }
742                }
743                None
744            }
745            Stmt::Try(try_stmt) => {
746                for s in &try_stmt.body {
747                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
748                        return Some(line);
749                    }
750                }
751                for handler in &try_stmt.handlers {
752                    let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
753                    for s in &h.body {
754                        if let Some(line) = self.find_yield_in_stmt(s, line_index) {
755                            return Some(line);
756                        }
757                    }
758                }
759                for s in &try_stmt.orelse {
760                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
761                        return Some(line);
762                    }
763                }
764                for s in &try_stmt.finalbody {
765                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
766                        return Some(line);
767                    }
768                }
769                None
770            }
771            Stmt::For(for_stmt) => {
772                for s in &for_stmt.body {
773                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
774                        return Some(line);
775                    }
776                }
777                for s in &for_stmt.orelse {
778                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
779                        return Some(line);
780                    }
781                }
782                None
783            }
784            Stmt::AsyncFor(for_stmt) => {
785                for s in &for_stmt.body {
786                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
787                        return Some(line);
788                    }
789                }
790                for s in &for_stmt.orelse {
791                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
792                        return Some(line);
793                    }
794                }
795                None
796            }
797            Stmt::While(while_stmt) => {
798                for s in &while_stmt.body {
799                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
800                        return Some(line);
801                    }
802                }
803                for s in &while_stmt.orelse {
804                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
805                        return Some(line);
806                    }
807                }
808                None
809            }
810            _ => None,
811        }
812    }
813
814    /// Find yield expression and return its line number.
815    fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
816        match expr {
817            Expr::Yield(yield_expr) => {
818                let line =
819                    self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
820                Some(line)
821            }
822            Expr::YieldFrom(yield_from) => {
823                let line =
824                    self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
825                Some(line)
826            }
827            _ => None,
828        }
829    }
830}
831
832// Undeclared fixtures scanning methods are in undeclared.rs