pytest_language_server/fixtures/
analyzer.rs

1//! File analysis and AST parsing for fixture extraction.
2//!
3//! This module contains all the logic for parsing Python files and extracting
4//! fixture definitions, usages, and undeclared fixtures.
5
6use super::decorators;
7use super::types::{FixtureDefinition, FixtureUsage, UndeclaredFixture};
8use super::FixtureDatabase;
9use rustpython_parser::ast::{ArgWithDefault, Arguments, Expr, Stmt};
10use rustpython_parser::{parse, Mode};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use tracing::{debug, error, info};
14
15impl FixtureDatabase {
16    /// Analyze a Python file for fixtures and usages.
17    /// This is the public API - it cleans up previous definitions before analyzing.
18    pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
19        self.analyze_file_internal(file_path, content, true);
20    }
21
22    /// Analyze a file without cleaning up previous definitions.
23    /// Used during initial workspace scan when we know the database is empty.
24    pub(crate) fn analyze_file_fresh(&self, file_path: PathBuf, content: &str) {
25        self.analyze_file_internal(file_path, content, false);
26    }
27
28    /// Internal file analysis with optional cleanup of previous definitions
29    fn analyze_file_internal(&self, file_path: PathBuf, content: &str, cleanup_previous: bool) {
30        // Use cached canonical path to avoid repeated filesystem calls
31        let file_path = self.get_canonical_path(file_path);
32
33        debug!("Analyzing file: {:?}", file_path);
34
35        // Cache the file content for later use (e.g., in find_fixture_definition)
36        // Use Arc for efficient sharing without cloning
37        self.file_cache
38            .insert(file_path.clone(), std::sync::Arc::new(content.to_string()));
39
40        // Parse the Python code
41        let parsed = match parse(content, Mode::Module, "") {
42            Ok(ast) => ast,
43            Err(e) => {
44                error!("Failed to parse Python file {:?}: {}", file_path, e);
45                return;
46            }
47        };
48
49        // Clear previous usages for this file
50        self.usages.remove(&file_path);
51
52        // Clear previous undeclared fixtures for this file
53        self.undeclared_fixtures.remove(&file_path);
54
55        // Clear previous imports for this file
56        self.imports.remove(&file_path);
57
58        // Note: line_index_cache uses content-hash-based invalidation,
59        // so we don't need to clear it here - get_line_index will detect
60        // if the content has changed and rebuild if necessary.
61
62        // Clear previous fixture definitions from this file (only when re-analyzing)
63        // Skip this during initial workspace scan for performance
64        if cleanup_previous {
65            self.cleanup_definitions_for_file(&file_path);
66        }
67
68        // Check if this is a conftest.py
69        let is_conftest = file_path
70            .file_name()
71            .map(|n| n == "conftest.py")
72            .unwrap_or(false);
73        debug!("is_conftest: {}", is_conftest);
74
75        // Get or build line index for O(1) line lookups (cached for performance)
76        let line_index = self.get_line_index(&file_path, content);
77
78        // Process each statement in the module
79        if let rustpython_parser::ast::Mod::Module(module) = parsed {
80            debug!("Module has {} statements", module.body.len());
81
82            // First pass: collect all module-level names (imports, assignments, function/class defs)
83            let mut module_level_names = HashSet::new();
84            for stmt in &module.body {
85                self.collect_module_level_names(stmt, &mut module_level_names);
86            }
87            self.imports.insert(file_path.clone(), module_level_names);
88
89            // Second pass: analyze fixtures and tests
90            for stmt in &module.body {
91                self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
92            }
93        }
94
95        debug!("Analysis complete for {:?}", file_path);
96    }
97
98    /// Remove definitions that were in a specific file
99    fn cleanup_definitions_for_file(&self, file_path: &PathBuf) {
100        // IMPORTANT: Collect keys first to avoid deadlock. The issue is that
101        // iter() holds read locks on the DashMap, and if we try to call .get() or
102        // .insert() on the same map while iterating, we'll deadlock due to lock
103        // contention. Collecting keys first releases the iterator locks before
104        // we start mutating the map.
105        let keys: Vec<String> = {
106            let mut k = Vec::new();
107            for entry in self.definitions.iter() {
108                k.push(entry.key().clone());
109            }
110            k
111        }; // Iterator dropped here, all locks released
112
113        // Now process each key individually
114        for key in keys {
115            // Get current definitions for this key
116            let current_defs = match self.definitions.get(&key) {
117                Some(defs) => defs.clone(),
118                None => continue,
119            };
120
121            // Filter out definitions from this file
122            let filtered: Vec<FixtureDefinition> = current_defs
123                .iter()
124                .filter(|def| def.file_path != *file_path)
125                .cloned()
126                .collect();
127
128            // Update or remove
129            if filtered.is_empty() {
130                self.definitions.remove(&key);
131            } else if filtered.len() != current_defs.len() {
132                // Only update if something changed
133                self.definitions.insert(key, filtered);
134            }
135        }
136    }
137
138    /// Build an index of line start offsets for O(1) line number lookups
139    pub(crate) fn build_line_index(content: &str) -> Vec<usize> {
140        let mut line_index = Vec::with_capacity(content.len() / 30);
141        line_index.push(0);
142        for (i, c) in content.char_indices() {
143            if c == '\n' {
144                line_index.push(i + 1);
145            }
146        }
147        line_index
148    }
149
150    /// Get line number (1-based) from byte offset
151    pub(crate) fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
152        match line_index.binary_search(&offset) {
153            Ok(line) => line + 1,
154            Err(line) => line,
155        }
156    }
157
158    /// Get character position within a line from byte offset
159    pub(crate) fn get_char_position_from_offset(
160        &self,
161        offset: usize,
162        line_index: &[usize],
163    ) -> usize {
164        let line = self.get_line_from_offset(offset, line_index);
165        let line_start = line_index[line - 1];
166        offset.saturating_sub(line_start)
167    }
168
169    /// Returns an iterator over all function arguments including positional-only,
170    /// regular positional, and keyword-only arguments.
171    /// This is needed because pytest fixtures can be declared as any of these types.
172    pub(crate) fn all_args(args: &Arguments) -> impl Iterator<Item = &ArgWithDefault> {
173        args.posonlyargs
174            .iter()
175            .chain(args.args.iter())
176            .chain(args.kwonlyargs.iter())
177    }
178
179    /// Helper to record a fixture usage in the database.
180    /// Reduces code duplication across multiple call sites.
181    fn record_fixture_usage(
182        &self,
183        file_path: &Path,
184        fixture_name: String,
185        line: usize,
186        start_char: usize,
187        end_char: usize,
188    ) {
189        let file_path_buf = file_path.to_path_buf();
190        let usage = FixtureUsage {
191            name: fixture_name,
192            file_path: file_path_buf.clone(),
193            line,
194            start_char,
195            end_char,
196        };
197        self.usages.entry(file_path_buf).or_default().push(usage);
198    }
199
200    /// Visit a statement and extract fixture definitions and usages
201    fn visit_stmt(
202        &self,
203        stmt: &Stmt,
204        file_path: &PathBuf,
205        _is_conftest: bool,
206        content: &str,
207        line_index: &[usize],
208    ) {
209        // First check for assignment-style fixtures: fixture_name = pytest.fixture()(func)
210        if let Stmt::Assign(assign) = stmt {
211            self.visit_assignment_fixture(assign, file_path, content, line_index);
212        }
213
214        // Handle class definitions - recurse into class body to find test methods
215        if let Stmt::ClassDef(class_def) = stmt {
216            // Check for @pytest.mark.usefixtures decorator on the class
217            for decorator in &class_def.decorator_list {
218                let usefixtures = decorators::extract_usefixtures_names(decorator);
219                for (fixture_name, range) in usefixtures {
220                    let usage_line =
221                        self.get_line_from_offset(range.start().to_usize(), line_index);
222                    let start_char =
223                        self.get_char_position_from_offset(range.start().to_usize(), line_index);
224                    let end_char =
225                        self.get_char_position_from_offset(range.end().to_usize(), line_index);
226
227                    info!(
228                        "Found usefixtures usage on class: {} at {:?}:{}:{}",
229                        fixture_name, file_path, usage_line, start_char
230                    );
231
232                    self.record_fixture_usage(
233                        file_path,
234                        fixture_name,
235                        usage_line,
236                        start_char + 1,
237                        end_char - 1,
238                    );
239                }
240            }
241
242            for class_stmt in &class_def.body {
243                self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
244            }
245            return;
246        }
247
248        // Handle both regular and async function definitions
249        let (func_name, decorator_list, args, range, body, returns) = match stmt {
250            Stmt::FunctionDef(func_def) => (
251                func_def.name.as_str(),
252                &func_def.decorator_list,
253                &func_def.args,
254                func_def.range,
255                &func_def.body,
256                &func_def.returns,
257            ),
258            Stmt::AsyncFunctionDef(func_def) => (
259                func_def.name.as_str(),
260                &func_def.decorator_list,
261                &func_def.args,
262                func_def.range,
263                &func_def.body,
264                &func_def.returns,
265            ),
266            _ => return,
267        };
268
269        debug!("Found function: {}", func_name);
270
271        // Check for @pytest.mark.usefixtures decorator on the function
272        for decorator in decorator_list {
273            let usefixtures = decorators::extract_usefixtures_names(decorator);
274            for (fixture_name, range) in usefixtures {
275                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
276                let start_char =
277                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
278                let end_char =
279                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
280
281                info!(
282                    "Found usefixtures usage on function: {} at {:?}:{}:{}",
283                    fixture_name, file_path, usage_line, start_char
284                );
285
286                self.record_fixture_usage(
287                    file_path,
288                    fixture_name,
289                    usage_line,
290                    start_char + 1,
291                    end_char - 1,
292                );
293            }
294        }
295
296        // Check for @pytest.mark.parametrize with indirect=True on the function
297        for decorator in decorator_list {
298            let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
299            for (fixture_name, range) in indirect_fixtures {
300                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
301                let start_char =
302                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
303                let end_char =
304                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
305
306                info!(
307                    "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
308                    fixture_name, file_path, usage_line, start_char
309                );
310
311                self.record_fixture_usage(
312                    file_path,
313                    fixture_name,
314                    usage_line,
315                    start_char + 1,
316                    end_char - 1,
317                );
318            }
319        }
320
321        // Check if this is a fixture definition
322        debug!(
323            "Function {} has {} decorators",
324            func_name,
325            decorator_list.len()
326        );
327        let fixture_decorator = decorator_list
328            .iter()
329            .find(|dec| decorators::is_fixture_decorator(dec));
330
331        if let Some(decorator) = fixture_decorator {
332            debug!("  Decorator matched as fixture!");
333
334            // Check if the fixture has a custom name
335            let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
336                .unwrap_or_else(|| func_name.to_string());
337
338            let line = self.get_line_from_offset(range.start().to_usize(), line_index);
339            let docstring = self.extract_docstring(body);
340            let return_type = self.extract_return_type(returns, body, content);
341
342            info!(
343                "Found fixture definition: {} (function: {}) at {:?}:{}",
344                fixture_name, func_name, file_path, line
345            );
346
347            let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
348
349            let is_third_party = file_path.to_string_lossy().contains("site-packages");
350            let definition = FixtureDefinition {
351                name: fixture_name.clone(),
352                file_path: file_path.clone(),
353                line,
354                start_char,
355                end_char,
356                docstring,
357                return_type,
358                is_third_party,
359            };
360
361            self.definitions
362                .entry(fixture_name)
363                .or_default()
364                .push(definition);
365
366            // Fixtures can depend on other fixtures - record these as usages too
367            let mut declared_params: HashSet<String> = HashSet::new();
368            declared_params.insert("self".to_string());
369            declared_params.insert("request".to_string());
370            declared_params.insert(func_name.to_string());
371
372            for arg in Self::all_args(args) {
373                let arg_name = arg.def.arg.as_str();
374                declared_params.insert(arg_name.to_string());
375
376                if arg_name != "self" && arg_name != "request" {
377                    let arg_line =
378                        self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
379                    let start_char = self.get_char_position_from_offset(
380                        arg.def.range.start().to_usize(),
381                        line_index,
382                    );
383                    let end_char = self
384                        .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
385
386                    info!(
387                        "Found fixture dependency: {} at {:?}:{}:{}",
388                        arg_name, file_path, arg_line, start_char
389                    );
390
391                    self.record_fixture_usage(
392                        file_path,
393                        arg_name.to_string(),
394                        arg_line,
395                        start_char,
396                        end_char,
397                    );
398                }
399            }
400
401            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
402            self.scan_function_body_for_undeclared_fixtures(
403                body,
404                file_path,
405                content,
406                line_index,
407                &declared_params,
408                func_name,
409                function_line,
410            );
411        }
412
413        // Check if this is a test function
414        let is_test = func_name.starts_with("test_");
415
416        if is_test {
417            debug!("Found test function: {}", func_name);
418
419            let mut declared_params: HashSet<String> = HashSet::new();
420            declared_params.insert("self".to_string());
421            declared_params.insert("request".to_string());
422
423            for arg in Self::all_args(args) {
424                let arg_name = arg.def.arg.as_str();
425                declared_params.insert(arg_name.to_string());
426
427                if arg_name != "self" {
428                    let arg_offset = arg.def.range.start().to_usize();
429                    let arg_line = self.get_line_from_offset(arg_offset, line_index);
430                    let start_char = self.get_char_position_from_offset(arg_offset, line_index);
431                    let end_char = self
432                        .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
433
434                    debug!(
435                        "Parameter {} at offset {}, calculated line {}, char {}",
436                        arg_name, arg_offset, arg_line, start_char
437                    );
438                    info!(
439                        "Found fixture usage: {} at {:?}:{}:{}",
440                        arg_name, file_path, arg_line, start_char
441                    );
442
443                    self.record_fixture_usage(
444                        file_path,
445                        arg_name.to_string(),
446                        arg_line,
447                        start_char,
448                        end_char,
449                    );
450                }
451            }
452
453            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
454            self.scan_function_body_for_undeclared_fixtures(
455                body,
456                file_path,
457                content,
458                line_index,
459                &declared_params,
460                func_name,
461                function_line,
462            );
463        }
464    }
465
466    /// Handle assignment-style fixtures: fixture_name = pytest.fixture()(func)
467    fn visit_assignment_fixture(
468        &self,
469        assign: &rustpython_parser::ast::StmtAssign,
470        file_path: &PathBuf,
471        _content: &str,
472        line_index: &[usize],
473    ) {
474        if let Expr::Call(outer_call) = &*assign.value {
475            if let Expr::Call(inner_call) = &*outer_call.func {
476                if decorators::is_fixture_decorator(&inner_call.func) {
477                    for target in &assign.targets {
478                        if let Expr::Name(name) = target {
479                            let fixture_name = name.id.as_str();
480                            let line = self
481                                .get_line_from_offset(assign.range.start().to_usize(), line_index);
482
483                            let start_char = self.get_char_position_from_offset(
484                                name.range.start().to_usize(),
485                                line_index,
486                            );
487                            let end_char = self.get_char_position_from_offset(
488                                name.range.end().to_usize(),
489                                line_index,
490                            );
491
492                            info!(
493                                "Found fixture assignment: {} at {:?}:{}:{}-{}",
494                                fixture_name, file_path, line, start_char, end_char
495                            );
496
497                            let is_third_party =
498                                file_path.to_string_lossy().contains("site-packages");
499                            let definition = FixtureDefinition {
500                                name: fixture_name.to_string(),
501                                file_path: file_path.clone(),
502                                line,
503                                start_char,
504                                end_char,
505                                docstring: None,
506                                return_type: None,
507                                is_third_party,
508                            };
509
510                            self.definitions
511                                .entry(fixture_name.to_string())
512                                .or_default()
513                                .push(definition);
514                        }
515                    }
516                }
517            }
518        }
519    }
520}
521
522// Second impl block for additional analyzer methods
523impl FixtureDatabase {
524    // ============ Module-level name collection ============
525
526    /// Collect all module-level names (imports, assignments, function/class defs)
527    fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
528        match stmt {
529            Stmt::Import(import_stmt) => {
530                for alias in &import_stmt.names {
531                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
532                    names.insert(name.to_string());
533                }
534            }
535            Stmt::ImportFrom(import_from) => {
536                for alias in &import_from.names {
537                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
538                    names.insert(name.to_string());
539                }
540            }
541            Stmt::FunctionDef(func_def) => {
542                let is_fixture = func_def
543                    .decorator_list
544                    .iter()
545                    .any(decorators::is_fixture_decorator);
546                if !is_fixture {
547                    names.insert(func_def.name.to_string());
548                }
549            }
550            Stmt::AsyncFunctionDef(func_def) => {
551                let is_fixture = func_def
552                    .decorator_list
553                    .iter()
554                    .any(decorators::is_fixture_decorator);
555                if !is_fixture {
556                    names.insert(func_def.name.to_string());
557                }
558            }
559            Stmt::ClassDef(class_def) => {
560                names.insert(class_def.name.to_string());
561            }
562            Stmt::Assign(assign) => {
563                for target in &assign.targets {
564                    self.collect_names_from_expr(target, names);
565                }
566            }
567            Stmt::AnnAssign(ann_assign) => {
568                self.collect_names_from_expr(&ann_assign.target, names);
569            }
570            _ => {}
571        }
572    }
573
574    #[allow(clippy::only_used_in_recursion)]
575    fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
576        match expr {
577            Expr::Name(name) => {
578                names.insert(name.id.to_string());
579            }
580            Expr::Tuple(tuple) => {
581                for elt in &tuple.elts {
582                    self.collect_names_from_expr(elt, names);
583                }
584            }
585            Expr::List(list) => {
586                for elt in &list.elts {
587                    self.collect_names_from_expr(elt, names);
588                }
589            }
590            _ => {}
591        }
592    }
593
594    // ============ Docstring and return type extraction ============
595
596    fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
597        if let Some(Stmt::Expr(expr_stmt)) = body.first() {
598            if let Expr::Constant(constant) = &*expr_stmt.value {
599                if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
600                    return Some(self.format_docstring(s.to_string()));
601                }
602            }
603        }
604        None
605    }
606
607    fn format_docstring(&self, docstring: String) -> String {
608        super::string_utils::format_docstring(docstring)
609    }
610
611    fn extract_return_type(
612        &self,
613        returns: &Option<Box<rustpython_parser::ast::Expr>>,
614        body: &[Stmt],
615        content: &str,
616    ) -> Option<String> {
617        if let Some(return_expr) = returns {
618            let has_yield = self.contains_yield(body);
619
620            if has_yield {
621                return self.extract_yielded_type(return_expr, content);
622            } else {
623                return Some(self.expr_to_string(return_expr, content));
624            }
625        }
626        None
627    }
628
629    #[allow(clippy::only_used_in_recursion)]
630    fn contains_yield(&self, body: &[Stmt]) -> bool {
631        for stmt in body {
632            match stmt {
633                Stmt::Expr(expr_stmt) => {
634                    if let Expr::Yield(_) | Expr::YieldFrom(_) = &*expr_stmt.value {
635                        return true;
636                    }
637                }
638                Stmt::If(if_stmt) => {
639                    if self.contains_yield(&if_stmt.body) || self.contains_yield(&if_stmt.orelse) {
640                        return true;
641                    }
642                }
643                Stmt::For(for_stmt) => {
644                    if self.contains_yield(&for_stmt.body) || self.contains_yield(&for_stmt.orelse)
645                    {
646                        return true;
647                    }
648                }
649                Stmt::While(while_stmt) => {
650                    if self.contains_yield(&while_stmt.body)
651                        || self.contains_yield(&while_stmt.orelse)
652                    {
653                        return true;
654                    }
655                }
656                Stmt::With(with_stmt) => {
657                    if self.contains_yield(&with_stmt.body) {
658                        return true;
659                    }
660                }
661                Stmt::Try(try_stmt) => {
662                    if self.contains_yield(&try_stmt.body)
663                        || self.contains_yield(&try_stmt.orelse)
664                        || self.contains_yield(&try_stmt.finalbody)
665                    {
666                        return true;
667                    }
668                }
669                _ => {}
670            }
671        }
672        false
673    }
674
675    fn extract_yielded_type(
676        &self,
677        expr: &rustpython_parser::ast::Expr,
678        content: &str,
679    ) -> Option<String> {
680        if let Expr::Subscript(subscript) = expr {
681            let _base_name = self.expr_to_string(&subscript.value, content);
682
683            if let Expr::Tuple(tuple) = &*subscript.slice {
684                if let Some(first_elem) = tuple.elts.first() {
685                    return Some(self.expr_to_string(first_elem, content));
686                }
687            } else {
688                return Some(self.expr_to_string(&subscript.slice, content));
689            }
690        }
691
692        Some(self.expr_to_string(expr, content))
693    }
694
695    #[allow(clippy::only_used_in_recursion)]
696    fn expr_to_string(&self, expr: &rustpython_parser::ast::Expr, content: &str) -> String {
697        match expr {
698            Expr::Name(name) => name.id.to_string(),
699            Expr::Attribute(attr) => {
700                format!(
701                    "{}.{}",
702                    self.expr_to_string(&attr.value, content),
703                    attr.attr
704                )
705            }
706            Expr::Subscript(subscript) => {
707                let base = self.expr_to_string(&subscript.value, content);
708                let slice = self.expr_to_string(&subscript.slice, content);
709                format!("{}[{}]", base, slice)
710            }
711            Expr::Tuple(tuple) => {
712                let elements: Vec<String> = tuple
713                    .elts
714                    .iter()
715                    .map(|e| self.expr_to_string(e, content))
716                    .collect();
717                elements.join(", ")
718            }
719            Expr::Constant(constant) => {
720                format!("{:?}", constant.value)
721            }
722            Expr::BinOp(binop) if matches!(binop.op, rustpython_parser::ast::Operator::BitOr) => {
723                format!(
724                    "{} | {}",
725                    self.expr_to_string(&binop.left, content),
726                    self.expr_to_string(&binop.right, content)
727                )
728            }
729            _ => "Any".to_string(),
730        }
731    }
732
733    /// Find the character position of a function name in a line
734    fn find_function_name_position(
735        &self,
736        content: &str,
737        line: usize,
738        func_name: &str,
739    ) -> (usize, usize) {
740        super::string_utils::find_function_name_position(content, line, func_name)
741    }
742}
743
744// Third impl block for undeclared fixtures scanning
745impl FixtureDatabase {
746    #[allow(clippy::too_many_arguments)]
747    fn scan_function_body_for_undeclared_fixtures(
748        &self,
749        body: &[Stmt],
750        file_path: &PathBuf,
751        content: &str,
752        line_index: &[usize],
753        declared_params: &HashSet<String>,
754        function_name: &str,
755        function_line: usize,
756    ) {
757        // First, collect all local variable names with their definition line numbers
758        let mut local_vars = HashMap::new();
759        self.collect_local_variables(body, content, line_index, &mut local_vars);
760
761        // Also add imported names to local_vars (they shouldn't be flagged as undeclared fixtures)
762        if let Some(imports) = self.imports.get(file_path) {
763            for import in imports.iter() {
764                local_vars.insert(import.clone(), 0);
765            }
766        }
767
768        // Walk through the function body and find all Name references
769        for stmt in body {
770            self.visit_stmt_for_names(
771                stmt,
772                file_path,
773                content,
774                line_index,
775                declared_params,
776                &local_vars,
777                function_name,
778                function_line,
779            );
780        }
781    }
782
783    #[allow(clippy::only_used_in_recursion)]
784    fn collect_local_variables(
785        &self,
786        body: &[Stmt],
787        content: &str,
788        line_index: &[usize],
789        local_vars: &mut HashMap<String, usize>,
790    ) {
791        for stmt in body {
792            match stmt {
793                Stmt::Assign(assign) => {
794                    let line =
795                        self.get_line_from_offset(assign.range.start().to_usize(), line_index);
796                    let mut temp_names = HashSet::new();
797                    for target in &assign.targets {
798                        self.collect_names_from_expr(target, &mut temp_names);
799                    }
800                    for name in temp_names {
801                        local_vars.insert(name, line);
802                    }
803                }
804                Stmt::AnnAssign(ann_assign) => {
805                    let line =
806                        self.get_line_from_offset(ann_assign.range.start().to_usize(), line_index);
807                    let mut temp_names = HashSet::new();
808                    self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
809                    for name in temp_names {
810                        local_vars.insert(name, line);
811                    }
812                }
813                Stmt::AugAssign(aug_assign) => {
814                    let line =
815                        self.get_line_from_offset(aug_assign.range.start().to_usize(), line_index);
816                    let mut temp_names = HashSet::new();
817                    self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
818                    for name in temp_names {
819                        local_vars.insert(name, line);
820                    }
821                }
822                Stmt::For(for_stmt) => {
823                    let line =
824                        self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
825                    let mut temp_names = HashSet::new();
826                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
827                    for name in temp_names {
828                        local_vars.insert(name, line);
829                    }
830                    self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
831                }
832                Stmt::AsyncFor(for_stmt) => {
833                    let line =
834                        self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
835                    let mut temp_names = HashSet::new();
836                    self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
837                    for name in temp_names {
838                        local_vars.insert(name, line);
839                    }
840                    self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
841                }
842                Stmt::While(while_stmt) => {
843                    self.collect_local_variables(&while_stmt.body, content, line_index, local_vars);
844                }
845                Stmt::If(if_stmt) => {
846                    self.collect_local_variables(&if_stmt.body, content, line_index, local_vars);
847                    self.collect_local_variables(&if_stmt.orelse, content, line_index, local_vars);
848                }
849                Stmt::With(with_stmt) => {
850                    let line =
851                        self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
852                    for item in &with_stmt.items {
853                        if let Some(ref optional_vars) = item.optional_vars {
854                            let mut temp_names = HashSet::new();
855                            self.collect_names_from_expr(optional_vars, &mut temp_names);
856                            for name in temp_names {
857                                local_vars.insert(name, line);
858                            }
859                        }
860                    }
861                    self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
862                }
863                Stmt::AsyncWith(with_stmt) => {
864                    let line =
865                        self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
866                    for item in &with_stmt.items {
867                        if let Some(ref optional_vars) = item.optional_vars {
868                            let mut temp_names = HashSet::new();
869                            self.collect_names_from_expr(optional_vars, &mut temp_names);
870                            for name in temp_names {
871                                local_vars.insert(name, line);
872                            }
873                        }
874                    }
875                    self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
876                }
877                Stmt::Try(try_stmt) => {
878                    self.collect_local_variables(&try_stmt.body, content, line_index, local_vars);
879                    self.collect_local_variables(&try_stmt.orelse, content, line_index, local_vars);
880                    self.collect_local_variables(
881                        &try_stmt.finalbody,
882                        content,
883                        line_index,
884                        local_vars,
885                    );
886                }
887                _ => {}
888            }
889        }
890    }
891
892    #[allow(clippy::too_many_arguments)]
893    fn visit_stmt_for_names(
894        &self,
895        stmt: &Stmt,
896        file_path: &PathBuf,
897        content: &str,
898        line_index: &[usize],
899        declared_params: &HashSet<String>,
900        local_vars: &HashMap<String, usize>,
901        function_name: &str,
902        function_line: usize,
903    ) {
904        match stmt {
905            Stmt::Expr(expr_stmt) => {
906                self.visit_expr_for_names(
907                    &expr_stmt.value,
908                    file_path,
909                    content,
910                    line_index,
911                    declared_params,
912                    local_vars,
913                    function_name,
914                    function_line,
915                );
916            }
917            Stmt::Assign(assign) => {
918                self.visit_expr_for_names(
919                    &assign.value,
920                    file_path,
921                    content,
922                    line_index,
923                    declared_params,
924                    local_vars,
925                    function_name,
926                    function_line,
927                );
928            }
929            Stmt::AugAssign(aug_assign) => {
930                self.visit_expr_for_names(
931                    &aug_assign.value,
932                    file_path,
933                    content,
934                    line_index,
935                    declared_params,
936                    local_vars,
937                    function_name,
938                    function_line,
939                );
940            }
941            Stmt::Return(ret) => {
942                if let Some(ref value) = ret.value {
943                    self.visit_expr_for_names(
944                        value,
945                        file_path,
946                        content,
947                        line_index,
948                        declared_params,
949                        local_vars,
950                        function_name,
951                        function_line,
952                    );
953                }
954            }
955            Stmt::If(if_stmt) => {
956                self.visit_expr_for_names(
957                    &if_stmt.test,
958                    file_path,
959                    content,
960                    line_index,
961                    declared_params,
962                    local_vars,
963                    function_name,
964                    function_line,
965                );
966                for stmt in &if_stmt.body {
967                    self.visit_stmt_for_names(
968                        stmt,
969                        file_path,
970                        content,
971                        line_index,
972                        declared_params,
973                        local_vars,
974                        function_name,
975                        function_line,
976                    );
977                }
978                for stmt in &if_stmt.orelse {
979                    self.visit_stmt_for_names(
980                        stmt,
981                        file_path,
982                        content,
983                        line_index,
984                        declared_params,
985                        local_vars,
986                        function_name,
987                        function_line,
988                    );
989                }
990            }
991            Stmt::While(while_stmt) => {
992                self.visit_expr_for_names(
993                    &while_stmt.test,
994                    file_path,
995                    content,
996                    line_index,
997                    declared_params,
998                    local_vars,
999                    function_name,
1000                    function_line,
1001                );
1002                for stmt in &while_stmt.body {
1003                    self.visit_stmt_for_names(
1004                        stmt,
1005                        file_path,
1006                        content,
1007                        line_index,
1008                        declared_params,
1009                        local_vars,
1010                        function_name,
1011                        function_line,
1012                    );
1013                }
1014            }
1015            Stmt::For(for_stmt) => {
1016                self.visit_expr_for_names(
1017                    &for_stmt.iter,
1018                    file_path,
1019                    content,
1020                    line_index,
1021                    declared_params,
1022                    local_vars,
1023                    function_name,
1024                    function_line,
1025                );
1026                for stmt in &for_stmt.body {
1027                    self.visit_stmt_for_names(
1028                        stmt,
1029                        file_path,
1030                        content,
1031                        line_index,
1032                        declared_params,
1033                        local_vars,
1034                        function_name,
1035                        function_line,
1036                    );
1037                }
1038            }
1039            Stmt::With(with_stmt) => {
1040                for item in &with_stmt.items {
1041                    self.visit_expr_for_names(
1042                        &item.context_expr,
1043                        file_path,
1044                        content,
1045                        line_index,
1046                        declared_params,
1047                        local_vars,
1048                        function_name,
1049                        function_line,
1050                    );
1051                }
1052                for stmt in &with_stmt.body {
1053                    self.visit_stmt_for_names(
1054                        stmt,
1055                        file_path,
1056                        content,
1057                        line_index,
1058                        declared_params,
1059                        local_vars,
1060                        function_name,
1061                        function_line,
1062                    );
1063                }
1064            }
1065            Stmt::AsyncFor(for_stmt) => {
1066                self.visit_expr_for_names(
1067                    &for_stmt.iter,
1068                    file_path,
1069                    content,
1070                    line_index,
1071                    declared_params,
1072                    local_vars,
1073                    function_name,
1074                    function_line,
1075                );
1076                for stmt in &for_stmt.body {
1077                    self.visit_stmt_for_names(
1078                        stmt,
1079                        file_path,
1080                        content,
1081                        line_index,
1082                        declared_params,
1083                        local_vars,
1084                        function_name,
1085                        function_line,
1086                    );
1087                }
1088            }
1089            Stmt::AsyncWith(with_stmt) => {
1090                for item in &with_stmt.items {
1091                    self.visit_expr_for_names(
1092                        &item.context_expr,
1093                        file_path,
1094                        content,
1095                        line_index,
1096                        declared_params,
1097                        local_vars,
1098                        function_name,
1099                        function_line,
1100                    );
1101                }
1102                for stmt in &with_stmt.body {
1103                    self.visit_stmt_for_names(
1104                        stmt,
1105                        file_path,
1106                        content,
1107                        line_index,
1108                        declared_params,
1109                        local_vars,
1110                        function_name,
1111                        function_line,
1112                    );
1113                }
1114            }
1115            Stmt::Assert(assert_stmt) => {
1116                self.visit_expr_for_names(
1117                    &assert_stmt.test,
1118                    file_path,
1119                    content,
1120                    line_index,
1121                    declared_params,
1122                    local_vars,
1123                    function_name,
1124                    function_line,
1125                );
1126                if let Some(ref msg) = assert_stmt.msg {
1127                    self.visit_expr_for_names(
1128                        msg,
1129                        file_path,
1130                        content,
1131                        line_index,
1132                        declared_params,
1133                        local_vars,
1134                        function_name,
1135                        function_line,
1136                    );
1137                }
1138            }
1139            _ => {}
1140        }
1141    }
1142
1143    #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
1144    fn visit_expr_for_names(
1145        &self,
1146        expr: &Expr,
1147        file_path: &PathBuf,
1148        content: &str,
1149        line_index: &[usize],
1150        declared_params: &HashSet<String>,
1151        local_vars: &HashMap<String, usize>,
1152        function_name: &str,
1153        function_line: usize,
1154    ) {
1155        match expr {
1156            Expr::Name(name) => {
1157                let name_str = name.id.as_str();
1158                let line = self.get_line_from_offset(name.range.start().to_usize(), line_index);
1159
1160                let is_local_var_in_scope = local_vars
1161                    .get(name_str)
1162                    .map(|def_line| *def_line < line)
1163                    .unwrap_or(false);
1164
1165                if !declared_params.contains(name_str)
1166                    && !is_local_var_in_scope
1167                    && self.is_available_fixture(file_path, name_str)
1168                {
1169                    let start_char = self
1170                        .get_char_position_from_offset(name.range.start().to_usize(), line_index);
1171                    let end_char =
1172                        self.get_char_position_from_offset(name.range.end().to_usize(), line_index);
1173
1174                    info!(
1175                        "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1176                        name_str, file_path, line, start_char, function_name
1177                    );
1178
1179                    let undeclared = UndeclaredFixture {
1180                        name: name_str.to_string(),
1181                        file_path: file_path.clone(),
1182                        line,
1183                        start_char,
1184                        end_char,
1185                        function_name: function_name.to_string(),
1186                        function_line,
1187                    };
1188
1189                    self.undeclared_fixtures
1190                        .entry(file_path.clone())
1191                        .or_default()
1192                        .push(undeclared);
1193                }
1194            }
1195            Expr::Call(call) => {
1196                self.visit_expr_for_names(
1197                    &call.func,
1198                    file_path,
1199                    content,
1200                    line_index,
1201                    declared_params,
1202                    local_vars,
1203                    function_name,
1204                    function_line,
1205                );
1206                for arg in &call.args {
1207                    self.visit_expr_for_names(
1208                        arg,
1209                        file_path,
1210                        content,
1211                        line_index,
1212                        declared_params,
1213                        local_vars,
1214                        function_name,
1215                        function_line,
1216                    );
1217                }
1218            }
1219            Expr::Attribute(attr) => {
1220                self.visit_expr_for_names(
1221                    &attr.value,
1222                    file_path,
1223                    content,
1224                    line_index,
1225                    declared_params,
1226                    local_vars,
1227                    function_name,
1228                    function_line,
1229                );
1230            }
1231            Expr::BinOp(binop) => {
1232                self.visit_expr_for_names(
1233                    &binop.left,
1234                    file_path,
1235                    content,
1236                    line_index,
1237                    declared_params,
1238                    local_vars,
1239                    function_name,
1240                    function_line,
1241                );
1242                self.visit_expr_for_names(
1243                    &binop.right,
1244                    file_path,
1245                    content,
1246                    line_index,
1247                    declared_params,
1248                    local_vars,
1249                    function_name,
1250                    function_line,
1251                );
1252            }
1253            Expr::UnaryOp(unaryop) => {
1254                self.visit_expr_for_names(
1255                    &unaryop.operand,
1256                    file_path,
1257                    content,
1258                    line_index,
1259                    declared_params,
1260                    local_vars,
1261                    function_name,
1262                    function_line,
1263                );
1264            }
1265            Expr::Compare(compare) => {
1266                self.visit_expr_for_names(
1267                    &compare.left,
1268                    file_path,
1269                    content,
1270                    line_index,
1271                    declared_params,
1272                    local_vars,
1273                    function_name,
1274                    function_line,
1275                );
1276                for comparator in &compare.comparators {
1277                    self.visit_expr_for_names(
1278                        comparator,
1279                        file_path,
1280                        content,
1281                        line_index,
1282                        declared_params,
1283                        local_vars,
1284                        function_name,
1285                        function_line,
1286                    );
1287                }
1288            }
1289            Expr::Subscript(subscript) => {
1290                self.visit_expr_for_names(
1291                    &subscript.value,
1292                    file_path,
1293                    content,
1294                    line_index,
1295                    declared_params,
1296                    local_vars,
1297                    function_name,
1298                    function_line,
1299                );
1300                self.visit_expr_for_names(
1301                    &subscript.slice,
1302                    file_path,
1303                    content,
1304                    line_index,
1305                    declared_params,
1306                    local_vars,
1307                    function_name,
1308                    function_line,
1309                );
1310            }
1311            Expr::List(list) => {
1312                for elt in &list.elts {
1313                    self.visit_expr_for_names(
1314                        elt,
1315                        file_path,
1316                        content,
1317                        line_index,
1318                        declared_params,
1319                        local_vars,
1320                        function_name,
1321                        function_line,
1322                    );
1323                }
1324            }
1325            Expr::Tuple(tuple) => {
1326                for elt in &tuple.elts {
1327                    self.visit_expr_for_names(
1328                        elt,
1329                        file_path,
1330                        content,
1331                        line_index,
1332                        declared_params,
1333                        local_vars,
1334                        function_name,
1335                        function_line,
1336                    );
1337                }
1338            }
1339            Expr::Dict(dict) => {
1340                for k in dict.keys.iter().flatten() {
1341                    self.visit_expr_for_names(
1342                        k,
1343                        file_path,
1344                        content,
1345                        line_index,
1346                        declared_params,
1347                        local_vars,
1348                        function_name,
1349                        function_line,
1350                    );
1351                }
1352                for value in &dict.values {
1353                    self.visit_expr_for_names(
1354                        value,
1355                        file_path,
1356                        content,
1357                        line_index,
1358                        declared_params,
1359                        local_vars,
1360                        function_name,
1361                        function_line,
1362                    );
1363                }
1364            }
1365            Expr::Await(await_expr) => {
1366                self.visit_expr_for_names(
1367                    &await_expr.value,
1368                    file_path,
1369                    content,
1370                    line_index,
1371                    declared_params,
1372                    local_vars,
1373                    function_name,
1374                    function_line,
1375                );
1376            }
1377            _ => {}
1378        }
1379    }
1380
1381    /// Check if a fixture is available at the given file location
1382    pub(crate) fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1383        if let Some(definitions) = self.definitions.get(fixture_name) {
1384            for def in definitions.iter() {
1385                // Fixture is available if it's in the same file
1386                if def.file_path == file_path {
1387                    return true;
1388                }
1389
1390                // Check if it's in a conftest.py in a parent directory
1391                if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1392                    && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1393                {
1394                    return true;
1395                }
1396
1397                // Check if it's in a virtual environment (third-party fixture)
1398                if def.is_third_party {
1399                    return true;
1400                }
1401            }
1402        }
1403        false
1404    }
1405}