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