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