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, FixtureUsage, TypeImportSpec};
9use super::FixtureDatabase;
10use once_cell::sync::Lazy;
11use rustpython_parser::ast::{ArgWithDefault, Arguments, Expr, Stmt};
12use rustpython_parser::{parse, Mode};
13use std::collections::{HashMap, HashSet};
14use std::path::{Path, PathBuf};
15use tracing::{debug, info};
16
17impl FixtureDatabase {
18    /// Analyze a Python file for fixtures and usages.
19    /// This is the public API - it cleans up previous definitions before analyzing.
20    pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
21        self.analyze_file_internal(file_path, content, true);
22    }
23
24    /// Analyze a file without cleaning up previous definitions.
25    /// Used during initial workspace scan when we know the database is empty.
26    pub(crate) fn analyze_file_fresh(&self, file_path: PathBuf, content: &str) {
27        self.analyze_file_internal(file_path, content, false);
28    }
29
30    /// Internal file analysis with optional cleanup of previous definitions
31    fn analyze_file_internal(&self, file_path: PathBuf, content: &str, cleanup_previous: bool) {
32        // Use cached canonical path to avoid repeated filesystem calls
33        let file_path = self.get_canonical_path(file_path);
34
35        debug!("Analyzing file: {:?}", file_path);
36
37        // Cache the file content for later use (e.g., in find_fixture_definition)
38        // Use Arc for efficient sharing without cloning
39        self.file_cache
40            .insert(file_path.clone(), std::sync::Arc::new(content.to_string()));
41
42        // Parse the Python code
43        let parsed = match parse(content, Mode::Module, "") {
44            Ok(ast) => ast,
45            Err(e) => {
46                // Keep existing fixture data when parse fails (user is likely editing)
47                // This provides better LSP experience during editing with syntax errors
48                debug!(
49                    "Failed to parse Python file {:?}: {} - keeping previous data",
50                    file_path, e
51                );
52                return;
53            }
54        };
55
56        // Clear previous usages for this file (only after successful parse)
57        self.cleanup_usages_for_file(&file_path);
58        self.usages.remove(&file_path);
59
60        // Clear previous undeclared fixtures for this file
61        self.undeclared_fixtures.remove(&file_path);
62
63        // Clear previous imports for this file
64        self.imports.remove(&file_path);
65
66        // Note: line_index_cache uses content-hash-based invalidation,
67        // so we don't need to clear it here - get_line_index will detect
68        // if the content has changed and rebuild if necessary.
69
70        // Clear previous fixture definitions from this file (only when re-analyzing)
71        // Skip this during initial workspace scan for performance
72        if cleanup_previous {
73            self.cleanup_definitions_for_file(&file_path);
74        }
75
76        // Check if this is a conftest.py
77        let is_conftest = file_path
78            .file_name()
79            .map(|n| n == "conftest.py")
80            .unwrap_or(false);
81        debug!("is_conftest: {}", is_conftest);
82
83        // Get or build line index for O(1) line lookups (cached for performance)
84        let line_index = self.get_line_index(&file_path, content);
85
86        // Process each statement in the module
87        if let rustpython_parser::ast::Mod::Module(module) = parsed {
88            debug!("Module has {} statements", module.body.len());
89
90            // First pass: collect all module-level names (imports, assignments, function/class defs)
91            let mut module_level_names = HashSet::new();
92            for stmt in &module.body {
93                self.collect_module_level_names(stmt, &mut module_level_names);
94            }
95            // Insert into DashMap *before* the second pass: undeclared-fixture
96            // scanning (`scan_function_body_for_undeclared_fixtures`) reads
97            // `self.imports` during `visit_stmt`, so the data must be available.
98            // The clone is unavoidable because `resolve_return_type_imports`
99            // also needs a local reference to the set.
100            self.imports
101                .insert(file_path.clone(), module_level_names.clone());
102
103            // Build a name→TypeImportSpec map from every import statement in the file.
104            // Used during fixture analysis to resolve return-type annotation imports.
105            let import_map = self.build_name_to_import_map(&module.body, &file_path);
106
107            // Collect type aliases so that `-> MyType` can be expanded to the
108            // underlying type before import resolution.
109            let type_aliases = self.collect_type_aliases(&module.body, content);
110
111            // Second pass: analyze fixtures and tests
112            for stmt in &module.body {
113                self.visit_stmt(
114                    stmt,
115                    &file_path,
116                    is_conftest,
117                    content,
118                    &line_index,
119                    &import_map,
120                    &module_level_names,
121                    &type_aliases,
122                );
123            }
124        }
125
126        debug!("Analysis complete for {:?}", file_path);
127
128        // Periodically evict cache entries to prevent unbounded memory growth
129        self.evict_cache_if_needed();
130    }
131
132    /// Remove definitions that were in a specific file.
133    /// Uses the file_definitions reverse index for efficient O(m) cleanup
134    /// where m = number of fixtures in this file, rather than O(n) where
135    /// n = total number of unique fixture names.
136    ///
137    /// Deadlock-free design:
138    /// 1. Atomically remove the set of fixture names from file_definitions
139    /// 2. For each fixture name, get a mutable reference, modify, then drop
140    /// 3. Only after dropping the reference, remove empty entries
141    fn cleanup_definitions_for_file(&self, file_path: &PathBuf) {
142        // Step 1: Atomically remove and get the fixture names for this file
143        let fixture_names = match self.file_definitions.remove(file_path) {
144            Some((_, names)) => names,
145            None => return, // No fixtures defined in this file
146        };
147
148        // Step 2: For each fixture name, remove definitions from this file
149        for fixture_name in fixture_names {
150            let should_remove = {
151                // Get mutable reference, modify in place, check if empty
152                if let Some(mut defs) = self.definitions.get_mut(&fixture_name) {
153                    defs.retain(|def| def.file_path != *file_path);
154                    defs.is_empty()
155                } else {
156                    false
157                }
158            }; // RefMut dropped here - safe to call remove_if now
159
160            // Step 3: Remove empty entries atomically
161            if should_remove {
162                // Use remove_if to ensure we only remove if still empty
163                // (another thread might have added a definition)
164                self.definitions
165                    .remove_if(&fixture_name, |_, defs| defs.is_empty());
166            }
167        }
168    }
169
170    /// Remove usages from the usage_by_fixture reverse index for a specific file.
171    /// Called before re-analyzing a file to clean up stale entries.
172    ///
173    /// Collects all keys first (without filtering) to avoid holding read locks
174    /// while doing the filter check, which could cause deadlocks.
175    fn cleanup_usages_for_file(&self, file_path: &PathBuf) {
176        // Collect all keys first to avoid holding any locks during iteration
177        let all_keys: Vec<String> = self
178            .usage_by_fixture
179            .iter()
180            .map(|entry| entry.key().clone())
181            .collect();
182
183        // Process each key - check if it has usages from this file and clean up
184        for fixture_name in all_keys {
185            let should_remove = {
186                if let Some(mut usages) = self.usage_by_fixture.get_mut(&fixture_name) {
187                    let had_usages = usages.iter().any(|(path, _)| path == file_path);
188                    if had_usages {
189                        usages.retain(|(path, _)| path != file_path);
190                    }
191                    usages.is_empty()
192                } else {
193                    false
194                }
195            };
196
197            if should_remove {
198                self.usage_by_fixture
199                    .remove_if(&fixture_name, |_, usages| usages.is_empty());
200            }
201        }
202    }
203
204    /// Build an index of line start offsets for O(1) line number lookups.
205    /// Uses memchr for SIMD-accelerated newline searching.
206    pub(crate) fn build_line_index(content: &str) -> Vec<usize> {
207        let bytes = content.as_bytes();
208        let mut line_index = Vec::with_capacity(content.len() / 30);
209        line_index.push(0);
210        for i in memchr::memchr_iter(b'\n', bytes) {
211            line_index.push(i + 1);
212        }
213        line_index
214    }
215
216    /// Get line number (1-based) from byte offset
217    pub(crate) fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
218        match line_index.binary_search(&offset) {
219            Ok(line) => line + 1,
220            Err(line) => line,
221        }
222    }
223
224    /// Get character position within a line from byte offset
225    pub(crate) fn get_char_position_from_offset(
226        &self,
227        offset: usize,
228        line_index: &[usize],
229    ) -> usize {
230        let line = self.get_line_from_offset(offset, line_index);
231        let line_start = line_index[line - 1];
232        offset.saturating_sub(line_start)
233    }
234
235    /// Returns an iterator over all function arguments including positional-only,
236    /// regular positional, and keyword-only arguments.
237    /// This is needed because pytest fixtures can be declared as any of these types.
238    pub(crate) fn all_args(args: &Arguments) -> impl Iterator<Item = &ArgWithDefault> {
239        args.posonlyargs
240            .iter()
241            .chain(args.args.iter())
242            .chain(args.kwonlyargs.iter())
243    }
244
245    /// Helper to record a fixture usage in the database.
246    /// Reduces code duplication across multiple call sites.
247    /// Also maintains usage_by_fixture reverse index for efficient reference lookups.
248    fn record_fixture_usage(
249        &self,
250        file_path: &Path,
251        fixture_name: String,
252        line: usize,
253        start_char: usize,
254        end_char: usize,
255        is_parameter: bool,
256    ) {
257        let file_path_buf = file_path.to_path_buf();
258        let usage = FixtureUsage {
259            name: fixture_name.clone(),
260            file_path: file_path_buf.clone(),
261            line,
262            start_char,
263            end_char,
264            is_parameter,
265        };
266
267        // Add to per-file usages map
268        self.usages
269            .entry(file_path_buf.clone())
270            .or_default()
271            .push(usage.clone());
272
273        // Add to reverse index for efficient reference lookups
274        self.usage_by_fixture
275            .entry(fixture_name)
276            .or_default()
277            .push((file_path_buf, usage));
278    }
279
280    /// Helper to record a fixture definition in the database.
281    /// Also maintains the file_definitions reverse index for efficient cleanup.
282    pub(crate) fn record_fixture_definition(&self, definition: FixtureDefinition) {
283        let file_path = definition.file_path.clone();
284        let fixture_name = definition.name.clone();
285
286        // Add to main definitions map
287        self.definitions
288            .entry(fixture_name.clone())
289            .or_default()
290            .push(definition);
291
292        // Maintain reverse index for efficient cleanup
293        self.file_definitions
294            .entry(file_path)
295            .or_default()
296            .insert(fixture_name);
297
298        // Invalidate cycle cache since definitions changed
299        self.invalidate_cycle_cache();
300    }
301
302    /// Visit a statement and extract fixture definitions and usages
303    #[allow(clippy::too_many_arguments)]
304    fn visit_stmt(
305        &self,
306        stmt: &Stmt,
307        file_path: &PathBuf,
308        _is_conftest: bool,
309        content: &str,
310        line_index: &[usize],
311        import_map: &HashMap<String, TypeImportSpec>,
312        module_level_names: &HashSet<String>,
313        type_aliases: &HashMap<String, String>,
314    ) {
315        // First check for assignment-style fixtures: fixture_name = pytest.fixture()(func)
316        if let Stmt::Assign(assign) = stmt {
317            self.visit_assignment_fixture(assign, file_path, content, line_index);
318
319            // Check for pytestmark = pytest.mark.usefixtures(...) or
320            // pytestmark = [pytest.mark.usefixtures(...), ...]
321            let is_pytestmark = assign.targets.iter().any(
322                |target| matches!(target, Expr::Name(name) if name.id.as_str() == "pytestmark"),
323            );
324            if is_pytestmark {
325                self.visit_pytestmark_assignment(Some(&assign.value), file_path, line_index);
326            }
327        }
328
329        // Check for annotated pytestmark: pytestmark: T = pytest.mark.usefixtures(...)
330        if let Stmt::AnnAssign(ann_assign) = stmt {
331            let is_pytestmark = matches!(
332                ann_assign.target.as_ref(),
333                Expr::Name(name) if name.id.as_str() == "pytestmark"
334            );
335            if is_pytestmark {
336                self.visit_pytestmark_assignment(
337                    ann_assign.value.as_deref(),
338                    file_path,
339                    line_index,
340                );
341            }
342        }
343
344        // Handle class definitions - recurse into class body to find test methods
345        if let Stmt::ClassDef(class_def) = stmt {
346            // Check for @pytest.mark.usefixtures decorator on the class
347            for decorator in &class_def.decorator_list {
348                let usefixtures = decorators::extract_usefixtures_names(decorator);
349                for (fixture_name, range) in usefixtures {
350                    let usage_line =
351                        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 class: {} 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                        false, // usefixtures string — not a function parameter
369                    );
370                }
371            }
372
373            for class_stmt in &class_def.body {
374                self.visit_stmt(
375                    class_stmt,
376                    file_path,
377                    _is_conftest,
378                    content,
379                    line_index,
380                    import_map,
381                    module_level_names,
382                    type_aliases,
383                );
384            }
385            return;
386        }
387
388        // Handle both regular and async function definitions
389        let (func_name, decorator_list, args, range, body, returns) = match stmt {
390            Stmt::FunctionDef(func_def) => (
391                func_def.name.as_str(),
392                &func_def.decorator_list,
393                &func_def.args,
394                func_def.range,
395                &func_def.body,
396                &func_def.returns,
397            ),
398            Stmt::AsyncFunctionDef(func_def) => (
399                func_def.name.as_str(),
400                &func_def.decorator_list,
401                &func_def.args,
402                func_def.range,
403                &func_def.body,
404                &func_def.returns,
405            ),
406            _ => return,
407        };
408
409        debug!("Found function: {}", func_name);
410
411        // Check for @pytest.mark.usefixtures decorator on the function
412        for decorator in decorator_list {
413            let usefixtures = decorators::extract_usefixtures_names(decorator);
414            for (fixture_name, range) in usefixtures {
415                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
416                let start_char =
417                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
418                let end_char =
419                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
420
421                info!(
422                    "Found usefixtures usage on function: {} at {:?}:{}:{}",
423                    fixture_name, file_path, usage_line, start_char
424                );
425
426                self.record_fixture_usage(
427                    file_path,
428                    fixture_name,
429                    usage_line,
430                    start_char + 1,
431                    end_char - 1,
432                    false, // usefixtures string — not a function parameter
433                );
434            }
435        }
436
437        // Check for @pytest.mark.parametrize with indirect=True on the function
438        for decorator in decorator_list {
439            let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
440            for (fixture_name, range) in indirect_fixtures {
441                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
442                let start_char =
443                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
444                let end_char =
445                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
446
447                info!(
448                    "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
449                    fixture_name, file_path, usage_line, start_char
450                );
451
452                self.record_fixture_usage(
453                    file_path,
454                    fixture_name,
455                    usage_line,
456                    start_char + 1,
457                    end_char - 1,
458                    false, // parametrize indirect string — not a function parameter
459                );
460            }
461        }
462
463        // Check if this is a fixture definition
464        debug!(
465            "Function {} has {} decorators",
466            func_name,
467            decorator_list.len()
468        );
469        let fixture_decorator = decorator_list
470            .iter()
471            .find(|dec| decorators::is_fixture_decorator(dec));
472
473        if let Some(decorator) = fixture_decorator {
474            debug!("  Decorator matched as fixture!");
475
476            // Check if the fixture has a custom name
477            let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
478                .unwrap_or_else(|| func_name.to_string());
479
480            // Extract scope from decorator (defaults to function scope)
481            let scope = decorators::extract_fixture_scope(decorator).unwrap_or_default();
482            let autouse = decorators::extract_fixture_autouse(decorator);
483
484            let line = self.get_line_from_offset(range.start().to_usize(), line_index);
485            let docstring = self.extract_docstring(body);
486            let raw_return_type = self.extract_return_type(returns, body, content);
487            let return_type = raw_return_type.map(|rt| {
488                if type_aliases.is_empty() {
489                    rt
490                } else {
491                    let expanded = Self::expand_type_aliases(&rt, type_aliases);
492                    if expanded != rt {
493                        info!(
494                            "Expanded type alias in fixture '{}': {} → {}",
495                            fixture_name, rt, expanded
496                        );
497                    }
498                    expanded
499                }
500            });
501            let return_type_imports = match &return_type {
502                Some(rt) => {
503                    self.resolve_return_type_imports(rt, import_map, module_level_names, file_path)
504                }
505                None => vec![],
506            };
507
508            info!(
509                "Found fixture definition: {} (function: {}, scope: {:?}) at {:?}:{}",
510                fixture_name, func_name, scope, file_path, line
511            );
512
513            let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
514
515            let is_third_party = file_path.to_string_lossy().contains("site-packages")
516                || self.is_editable_install_third_party(file_path);
517            let is_plugin = self.plugin_fixture_files.contains_key(file_path);
518
519            // Fixtures can depend on other fixtures - collect dependencies first
520            let mut declared_params: HashSet<String> = HashSet::new();
521            let mut dependencies: Vec<String> = Vec::new();
522            declared_params.insert("self".to_string());
523            declared_params.insert("request".to_string());
524            declared_params.insert(func_name.to_string());
525
526            for arg in Self::all_args(args) {
527                let arg_name = arg.def.arg.as_str();
528                declared_params.insert(arg_name.to_string());
529                // Track as dependency if it's not self/request (these are special)
530                if arg_name != "self" && arg_name != "request" {
531                    dependencies.push(arg_name.to_string());
532                }
533            }
534
535            // Calculate end line from the function's range
536            let end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
537
538            let definition = FixtureDefinition {
539                name: fixture_name.clone(),
540                file_path: file_path.clone(),
541                line,
542                end_line,
543                start_char,
544                end_char,
545                docstring,
546                return_type,
547                return_type_imports,
548                is_third_party,
549                is_plugin,
550                dependencies: dependencies.clone(),
551                scope,
552                yield_line: self.find_yield_line(body, line_index),
553                autouse,
554            };
555
556            self.record_fixture_definition(definition);
557
558            // Record each parameter as a usage (dependencies + special builtins like
559            // `request` that are not fixture dependencies but need inlay hints / code actions)
560            for arg in Self::all_args(args) {
561                let arg_name = arg.def.arg.as_str();
562
563                // `request` is excluded from *dependencies* (it is a special pytest
564                // injection, not a regular fixture), but we DO record it as a usage
565                // so that inlay hints and type-annotation code actions work on it.
566                if arg_name != "self" {
567                    let arg_line =
568                        self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
569                    let start_char = self.get_char_position_from_offset(
570                        arg.def.range.start().to_usize(),
571                        line_index,
572                    );
573                    // Use parameter name length, not AST range (which includes type annotation)
574                    let end_char = start_char + arg_name.len();
575
576                    info!(
577                        "Found fixture parameter usage: {} at {:?}:{}:{}",
578                        arg_name, file_path, arg_line, start_char
579                    );
580
581                    self.record_fixture_usage(
582                        file_path,
583                        arg_name.to_string(),
584                        arg_line,
585                        start_char,
586                        end_char,
587                        true, // actual function parameter — can receive a type annotation
588                    );
589                }
590            }
591
592            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
593            self.scan_function_body_for_undeclared_fixtures(
594                body,
595                file_path,
596                line_index,
597                &declared_params,
598                func_name,
599                function_line,
600            );
601        }
602
603        // Check if this is a test function
604        let is_test = func_name.starts_with("test_");
605
606        if is_test {
607            debug!("Found test function: {}", func_name);
608
609            let mut declared_params: HashSet<String> = HashSet::new();
610            declared_params.insert("self".to_string());
611            declared_params.insert("request".to_string());
612
613            for arg in Self::all_args(args) {
614                let arg_name = arg.def.arg.as_str();
615                declared_params.insert(arg_name.to_string());
616
617                if arg_name != "self" {
618                    let arg_offset = arg.def.range.start().to_usize();
619                    let arg_line = self.get_line_from_offset(arg_offset, line_index);
620                    let start_char = self.get_char_position_from_offset(arg_offset, line_index);
621                    // Use parameter name length, not AST range (which includes type annotation)
622                    let end_char = start_char + arg_name.len();
623
624                    debug!(
625                        "Parameter {} at offset {}, calculated line {}, char {}",
626                        arg_name, arg_offset, arg_line, start_char
627                    );
628                    info!(
629                        "Found fixture usage: {} at {:?}:{}:{}",
630                        arg_name, file_path, arg_line, start_char
631                    );
632
633                    self.record_fixture_usage(
634                        file_path,
635                        arg_name.to_string(),
636                        arg_line,
637                        start_char,
638                        end_char,
639                        true, // actual function parameter — can receive a type annotation
640                    );
641                }
642            }
643
644            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
645            self.scan_function_body_for_undeclared_fixtures(
646                body,
647                file_path,
648                line_index,
649                &declared_params,
650                func_name,
651                function_line,
652            );
653        }
654    }
655
656    /// Handle assignment-style fixtures: fixture_name = pytest.fixture()(func)
657    fn visit_assignment_fixture(
658        &self,
659        assign: &rustpython_parser::ast::StmtAssign,
660        file_path: &PathBuf,
661        _content: &str,
662        line_index: &[usize],
663    ) {
664        if let Expr::Call(outer_call) = &*assign.value {
665            if let Expr::Call(inner_call) = &*outer_call.func {
666                if decorators::is_fixture_decorator(&inner_call.func) {
667                    for target in &assign.targets {
668                        if let Expr::Name(name) = target {
669                            let fixture_name = name.id.as_str();
670                            let line = self
671                                .get_line_from_offset(assign.range.start().to_usize(), line_index);
672
673                            let start_char = self.get_char_position_from_offset(
674                                name.range.start().to_usize(),
675                                line_index,
676                            );
677                            let end_char = self.get_char_position_from_offset(
678                                name.range.end().to_usize(),
679                                line_index,
680                            );
681
682                            info!(
683                                "Found fixture assignment: {} at {:?}:{}:{}-{}",
684                                fixture_name, file_path, line, start_char, end_char
685                            );
686
687                            let is_third_party =
688                                file_path.to_string_lossy().contains("site-packages")
689                                    || self.is_editable_install_third_party(file_path);
690                            let is_plugin = self.plugin_fixture_files.contains_key(file_path);
691                            let definition = FixtureDefinition {
692                                name: fixture_name.to_string(),
693                                file_path: file_path.clone(),
694                                line,
695                                end_line: line, // Assignment-style fixtures are single-line
696                                start_char,
697                                end_char,
698                                docstring: None,
699                                return_type: None,
700                                return_type_imports: vec![],
701                                is_third_party,
702                                is_plugin,
703                                dependencies: Vec::new(), // Assignment-style fixtures don't have explicit dependencies
704                                scope: decorators::extract_fixture_scope(&outer_call.func)
705                                    .unwrap_or_default(),
706                                yield_line: None, // Assignment-style fixtures don't have yield statements
707                                autouse: false,   // Assignment-style fixtures are never autouse
708                            };
709
710                            self.record_fixture_definition(definition);
711                        }
712                    }
713                }
714            }
715        }
716    }
717
718    /// Handle pytestmark usefixtures — covers both plain and annotated assignments:
719    ///   pytestmark = pytest.mark.usefixtures("fix1", "fix2")
720    ///   pytestmark = [pytest.mark.usefixtures("fix1"), pytest.mark.skip]
721    ///   pytestmark = (pytest.mark.usefixtures("fix1"), pytest.mark.usefixtures("fix2"))
722    ///   pytestmark: list[MarkDecorator] = [pytest.mark.usefixtures("fix1"), ...]
723    ///
724    /// `value` is `None` for bare annotated assignments (`pytestmark: T`) which are a no-op.
725    fn visit_pytestmark_assignment(
726        &self,
727        value: Option<&Expr>,
728        file_path: &PathBuf,
729        line_index: &[usize],
730    ) {
731        let Some(value) = value else {
732            return;
733        };
734
735        let usefixtures = decorators::extract_usefixtures_from_expr(value);
736        for (fixture_name, range) in usefixtures {
737            let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
738            let start_char =
739                self.get_char_position_from_offset(range.start().to_usize(), line_index);
740            let end_char = self.get_char_position_from_offset(range.end().to_usize(), line_index);
741
742            info!(
743                "Found usefixtures usage via pytestmark assignment: {} at {:?}:{}:{}",
744                fixture_name, file_path, usage_line, start_char
745            );
746
747            self.record_fixture_usage(
748                file_path,
749                fixture_name,
750                usage_line,
751                start_char.saturating_add(1),
752                end_char.saturating_sub(1),
753                false, // pytestmark usefixtures string — not a function parameter
754            );
755        }
756    }
757}
758
759/// Python builtin types that never require an import statement.
760/// Uses O(1) `HashSet` lookup, consistent with `is_standard_library_module()`.
761static BUILTINS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
762    [
763        "int",
764        "str",
765        "bool",
766        "float",
767        "bytes",
768        "bytearray",
769        "complex",
770        "list",
771        "dict",
772        "tuple",
773        "set",
774        "frozenset",
775        "type",
776        "object",
777        "None",
778        "range",
779        "slice",
780        "memoryview",
781        "property",
782        "classmethod",
783        "staticmethod",
784        "super",
785        "Exception",
786        "BaseException",
787        "ValueError",
788        "TypeError",
789        "RuntimeError",
790        "NotImplementedError",
791        "AttributeError",
792        "KeyError",
793        "IndexError",
794        "StopIteration",
795        "GeneratorExit",
796    ]
797    .into_iter()
798    .collect()
799});
800
801// Second impl block for additional analyzer methods
802impl FixtureDatabase {
803    // ============ Type alias resolution ============
804
805    /// Collect type aliases defined at module level.
806    ///
807    /// Recognises two forms:
808    ///
809    /// 1. **PEP 613** — `MyType: TypeAlias = Dict[str, int]`
810    ///    (`Stmt::AnnAssign` where the annotation mentions `TypeAlias`)
811    /// 2. **Old-style** — `MyType = Dict[str, int]`
812    ///    (`Stmt::Assign` where the target is a single `Expr::Name` whose
813    ///    first character is uppercase and the RHS looks like a type expression)
814    ///
815    /// Returns a mapping from alias name to the expanded type string.
816    pub(crate) fn collect_type_aliases(
817        &self,
818        stmts: &[Stmt],
819        content: &str,
820    ) -> HashMap<String, String> {
821        let mut aliases = HashMap::new();
822
823        for stmt in stmts {
824            match stmt {
825                // PEP 613: `X: TypeAlias = <type_expr>`
826                Stmt::AnnAssign(ann_assign) => {
827                    if !Self::annotation_is_type_alias(&ann_assign.annotation) {
828                        continue;
829                    }
830                    let Expr::Name(name) = ann_assign.target.as_ref() else {
831                        continue;
832                    };
833                    let Some(value) = &ann_assign.value else {
834                        continue;
835                    };
836                    let expanded = self.expr_to_string(value, content);
837                    // Skip aliases that expand to raw `Any`: if the fixture file
838                    // writes `MyType: TypeAlias = Any`, the alias name `MyType` is
839                    // still in `module_level_names`, so `resolve_return_type_imports`
840                    // will correctly generate `from <module> import MyType` for it.
841                    // Expanding to `Any` would instead require adding
842                    // `from typing import Any`, which misrepresents the intent.
843                    if expanded != "Any" {
844                        debug!("Type alias (PEP 613): {} = {}", name.id, expanded);
845                        aliases.insert(name.id.to_string(), expanded);
846                    }
847                }
848
849                // Old-style: `X = <type_expr>` where X starts with uppercase
850                Stmt::Assign(assign) => {
851                    if assign.targets.len() != 1 {
852                        continue;
853                    }
854                    let Expr::Name(name) = &assign.targets[0] else {
855                        continue;
856                    };
857                    // Heuristic: type alias names start with an uppercase letter.
858                    if !name.id.starts_with(|c: char| c.is_ascii_uppercase()) {
859                        continue;
860                    }
861                    if !Self::expr_looks_like_type(&assign.value) {
862                        continue;
863                    }
864                    let expanded = self.expr_to_string(&assign.value, content);
865                    // Same rationale as the PEP 613 branch above: skip `Any`-valued
866                    // aliases so the alias name keeps its locally-defined import path.
867                    if expanded != "Any" {
868                        debug!("Type alias (old-style): {} = {}", name.id, expanded);
869                        aliases.insert(name.id.to_string(), expanded);
870                    }
871                }
872
873                _ => {}
874            }
875        }
876
877        aliases
878    }
879
880    /// Check whether an annotation expression refers to `TypeAlias`.
881    ///
882    /// Matches `TypeAlias`, `typing.TypeAlias`, and `typing_extensions.TypeAlias`.
883    fn annotation_is_type_alias(expr: &Expr) -> bool {
884        match expr {
885            Expr::Name(name) => name.id.as_str() == "TypeAlias",
886            Expr::Attribute(attr) => {
887                attr.attr.as_str() == "TypeAlias"
888                    && matches!(
889                        attr.value.as_ref(),
890                        Expr::Name(n) if n.id.as_str() == "typing" || n.id.as_str() == "typing_extensions"
891                    )
892            }
893            _ => false,
894        }
895    }
896
897    /// Heuristic: does an expression look like a type annotation?
898    ///
899    /// Returns `true` for subscripts (`Dict[str, int]`), union operators
900    /// (`int | str`), names (`Path`), attributes (`pathlib.Path`), `None`,
901    /// and string literals (forward references like `"MyClass"`).
902    fn expr_looks_like_type(expr: &Expr) -> bool {
903        match expr {
904            // Subscript: Dict[str, int], Optional[Path], list[int], etc.
905            Expr::Subscript(_) => true,
906            // Union: int | str
907            Expr::BinOp(binop) => {
908                matches!(binop.op, rustpython_parser::ast::Operator::BitOr)
909                    && Self::expr_looks_like_type(&binop.left)
910                    && Self::expr_looks_like_type(&binop.right)
911            }
912            // Simple name: uppercase (Path, MyClass) or a known builtin (str, int, …)
913            Expr::Name(name) => {
914                name.id.starts_with(|c: char| c.is_ascii_uppercase())
915                    || BUILTINS.contains(name.id.as_str())
916            }
917            // Attribute: pathlib.Path
918            Expr::Attribute(_) => true,
919            // None literal or string literal (forward reference)
920            Expr::Constant(c) => matches!(
921                c.value,
922                rustpython_parser::ast::Constant::None | rustpython_parser::ast::Constant::Str(_)
923            ),
924            _ => false,
925        }
926    }
927
928    /// Expand type aliases in a return-type string.
929    ///
930    /// Performs a single pass of word-boundary-safe substitution. Each
931    /// standalone identifier that matches a key in `type_aliases` is replaced
932    /// with the expanded form.  A match is "standalone" when it is not
933    /// preceded or followed by an alphanumeric character, underscore, or dot
934    /// (preventing partial matches like `MyTypeExtra`).
935    ///
936    /// Expansion is applied at most `MAX_DEPTH` times to handle aliases that
937    /// reference other aliases (e.g. `A = B`, `B = Dict[str, int]`).
938    pub(crate) fn expand_type_aliases(
939        type_str: &str,
940        type_aliases: &HashMap<String, String>,
941    ) -> String {
942        const MAX_DEPTH: usize = 5;
943        let mut result = type_str.to_string();
944
945        for _ in 0..MAX_DEPTH {
946            let mut changed = false;
947            for (alias, expanded) in type_aliases {
948                let new = super::string_utils::replace_identifier(&result, alias, expanded);
949                if new != result {
950                    result = new;
951                    changed = true;
952                }
953            }
954            if !changed {
955                break;
956            }
957        }
958
959        result
960    }
961
962    // ============ Return-type import resolution ============
963
964    /// Extract all distinct identifier tokens from a type annotation string.
965    ///
966    /// Walks the string collecting runs of `[a-zA-Z_][a-zA-Z0-9_]*` characters.
967    /// Dotted names like `pathlib.Path` produce two separate tokens (`pathlib`,
968    /// `Path`) — each is looked up independently in the import map, which is
969    /// correct because:
970    /// - `import pathlib` → `import_map["pathlib"]` matches `pathlib`
971    /// - `from pathlib import Path` → `import_map["Path"]` matches `Path`
972    ///
973    /// # Examples
974    /// - `"dict[str, Any]"` → `["dict", "str", "Any"]`
975    /// - `"Optional[Path]"` → `["Optional", "Path"]`
976    /// - `"pathlib.Path"` → `["pathlib", "Path"]`
977    /// - `"Path | None"` → `["Path", "None"]`
978    /// - `"list[dict[str, Any]]"` → `["list", "dict", "str", "Any"]`
979    fn extract_type_identifiers(type_str: &str) -> Vec<&str> {
980        let mut identifiers = Vec::new();
981        let mut seen = HashSet::new();
982        let bytes = type_str.as_bytes();
983        let len = bytes.len();
984        let mut i = 0;
985
986        while i < len {
987            let b = bytes[i];
988            // Start of an identifier: [a-zA-Z_]
989            if b.is_ascii_alphabetic() || b == b'_' {
990                let start = i;
991                i += 1;
992                while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
993                    i += 1;
994                }
995                let ident = &type_str[start..i];
996                if seen.insert(ident) {
997                    identifiers.push(ident);
998                }
999            } else {
1000                i += 1;
1001            }
1002        }
1003
1004        identifiers
1005    }
1006
1007    /// Resolve the import spec(s) needed to use a fixture's return type
1008    /// annotation in a consumer file (e.g. a test file).
1009    ///
1010    /// Handles simple types (`Path`), dotted names (`pathlib.Path`), generics
1011    /// (`Optional[Path]`, `dict[str, Any]`), unions (`Path | None`), and any
1012    /// nesting thereof.  Every identifier token in the type string is resolved
1013    /// independently.
1014    ///
1015    /// Resolution order **per identifier**:
1016    /// 1. Builtin types (`int`, `str`, …) — skip, no import needed.
1017    /// 2. Look up in `import_map` (built from the fixture file's imports).
1018    /// 3. If the name is locally defined in the fixture file (class,
1019    ///    assignment, …) but not imported, build an import from
1020    ///    `fixture_file`'s module path.
1021    /// 4. Otherwise skip.
1022    ///
1023    /// Results are deduplicated by `check_name`.
1024    fn resolve_return_type_imports(
1025        &self,
1026        return_type: &str,
1027        import_map: &HashMap<String, TypeImportSpec>,
1028        module_level_names: &HashSet<String>,
1029        fixture_file: &Path,
1030    ) -> Vec<TypeImportSpec> {
1031        let identifiers = Self::extract_type_identifiers(return_type);
1032        let mut specs: Vec<TypeImportSpec> = Vec::new();
1033        let mut seen: HashSet<&str> = HashSet::new();
1034
1035        for ident in identifiers {
1036            // Skip builtins — they never need an import.
1037            if BUILTINS.contains(ident) {
1038                continue;
1039            }
1040
1041            // Avoid duplicates (e.g. `tuple[Path, Path]`).
1042            if !seen.insert(ident) {
1043                continue;
1044            }
1045
1046            // Check the import map (covers `import X` and `from X import Y`).
1047            if let Some(spec) = import_map.get(ident) {
1048                specs.push(spec.clone());
1049                continue;
1050            }
1051
1052            // If the name is defined locally in the fixture file (e.g. a class
1053            // in conftest.py), build an import from that file's module path.
1054            if module_level_names.contains(ident) {
1055                if let Some(module_path) = Self::file_path_to_module_path(fixture_file) {
1056                    specs.push(TypeImportSpec {
1057                        check_name: ident.to_string(),
1058                        import_statement: format!("from {} import {}", module_path, ident),
1059                    });
1060                }
1061            }
1062        }
1063
1064        specs
1065    }
1066
1067    // ============ Module-level name collection ============
1068
1069    /// Collect all module-level names (imports, assignments, function/class defs)
1070    fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
1071        match stmt {
1072            Stmt::Import(import_stmt) => {
1073                for alias in &import_stmt.names {
1074                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
1075                    names.insert(name.to_string());
1076                }
1077            }
1078            Stmt::ImportFrom(import_from) => {
1079                for alias in &import_from.names {
1080                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
1081                    names.insert(name.to_string());
1082                }
1083            }
1084            Stmt::FunctionDef(func_def) => {
1085                let is_fixture = func_def
1086                    .decorator_list
1087                    .iter()
1088                    .any(decorators::is_fixture_decorator);
1089                if !is_fixture {
1090                    names.insert(func_def.name.to_string());
1091                }
1092            }
1093            Stmt::AsyncFunctionDef(func_def) => {
1094                let is_fixture = func_def
1095                    .decorator_list
1096                    .iter()
1097                    .any(decorators::is_fixture_decorator);
1098                if !is_fixture {
1099                    names.insert(func_def.name.to_string());
1100                }
1101            }
1102            Stmt::ClassDef(class_def) => {
1103                names.insert(class_def.name.to_string());
1104            }
1105            Stmt::Assign(assign) => {
1106                for target in &assign.targets {
1107                    self.collect_names_from_expr(target, names);
1108                }
1109            }
1110            Stmt::AnnAssign(ann_assign) => {
1111                self.collect_names_from_expr(&ann_assign.target, names);
1112            }
1113            _ => {}
1114        }
1115    }
1116
1117    #[allow(clippy::only_used_in_recursion)]
1118    pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
1119        match expr {
1120            Expr::Name(name) => {
1121                names.insert(name.id.to_string());
1122            }
1123            Expr::Tuple(tuple) => {
1124                for elt in &tuple.elts {
1125                    self.collect_names_from_expr(elt, names);
1126                }
1127            }
1128            Expr::List(list) => {
1129                for elt in &list.elts {
1130                    self.collect_names_from_expr(elt, names);
1131                }
1132            }
1133            _ => {}
1134        }
1135    }
1136
1137    // Docstring and return type extraction methods are in docstring.rs
1138
1139    /// Find the character position of a function name in a line
1140    fn find_function_name_position(
1141        &self,
1142        content: &str,
1143        line: usize,
1144        func_name: &str,
1145    ) -> (usize, usize) {
1146        super::string_utils::find_function_name_position(content, line, func_name)
1147    }
1148
1149    /// Find the line number of the first yield statement in a function body.
1150    /// Returns None if no yield statement is found.
1151    fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
1152        for stmt in body {
1153            if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
1154                return Some(line);
1155            }
1156        }
1157        None
1158    }
1159
1160    /// Recursively search for yield statements in a statement.
1161    fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
1162        match stmt {
1163            Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
1164            Stmt::If(if_stmt) => {
1165                // Check body
1166                for s in &if_stmt.body {
1167                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1168                        return Some(line);
1169                    }
1170                }
1171                // Check elif/else
1172                for s in &if_stmt.orelse {
1173                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1174                        return Some(line);
1175                    }
1176                }
1177                None
1178            }
1179            Stmt::With(with_stmt) => {
1180                for s in &with_stmt.body {
1181                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1182                        return Some(line);
1183                    }
1184                }
1185                None
1186            }
1187            Stmt::AsyncWith(with_stmt) => {
1188                for s in &with_stmt.body {
1189                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1190                        return Some(line);
1191                    }
1192                }
1193                None
1194            }
1195            Stmt::Try(try_stmt) => {
1196                for s in &try_stmt.body {
1197                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1198                        return Some(line);
1199                    }
1200                }
1201                for handler in &try_stmt.handlers {
1202                    let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
1203                    for s in &h.body {
1204                        if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1205                            return Some(line);
1206                        }
1207                    }
1208                }
1209                for s in &try_stmt.orelse {
1210                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1211                        return Some(line);
1212                    }
1213                }
1214                for s in &try_stmt.finalbody {
1215                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1216                        return Some(line);
1217                    }
1218                }
1219                None
1220            }
1221            Stmt::For(for_stmt) => {
1222                for s in &for_stmt.body {
1223                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1224                        return Some(line);
1225                    }
1226                }
1227                for s in &for_stmt.orelse {
1228                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1229                        return Some(line);
1230                    }
1231                }
1232                None
1233            }
1234            Stmt::AsyncFor(for_stmt) => {
1235                for s in &for_stmt.body {
1236                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1237                        return Some(line);
1238                    }
1239                }
1240                for s in &for_stmt.orelse {
1241                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1242                        return Some(line);
1243                    }
1244                }
1245                None
1246            }
1247            Stmt::While(while_stmt) => {
1248                for s in &while_stmt.body {
1249                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1250                        return Some(line);
1251                    }
1252                }
1253                for s in &while_stmt.orelse {
1254                    if let Some(line) = self.find_yield_in_stmt(s, line_index) {
1255                        return Some(line);
1256                    }
1257                }
1258                None
1259            }
1260            _ => None,
1261        }
1262    }
1263
1264    /// Find yield expression and return its line number.
1265    fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
1266        match expr {
1267            Expr::Yield(yield_expr) => {
1268                let line =
1269                    self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
1270                Some(line)
1271            }
1272            Expr::YieldFrom(yield_from) => {
1273                let line =
1274                    self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
1275                Some(line)
1276            }
1277            _ => None,
1278        }
1279    }
1280}
1281
1282// Undeclared fixtures scanning methods are in undeclared.rs