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};
9use super::FixtureDatabase;
10use rustpython_parser::ast::{ArgWithDefault, Arguments, Expr, Stmt};
11use rustpython_parser::{parse, Mode};
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use tracing::{debug, error, info};
15
16impl FixtureDatabase {
17    /// Analyze a Python file for fixtures and usages.
18    /// This is the public API - it cleans up previous definitions before analyzing.
19    pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
20        self.analyze_file_internal(file_path, content, true);
21    }
22
23    /// Analyze a file without cleaning up previous definitions.
24    /// Used during initial workspace scan when we know the database is empty.
25    pub(crate) fn analyze_file_fresh(&self, file_path: PathBuf, content: &str) {
26        self.analyze_file_internal(file_path, content, false);
27    }
28
29    /// Internal file analysis with optional cleanup of previous definitions
30    fn analyze_file_internal(&self, file_path: PathBuf, content: &str, cleanup_previous: bool) {
31        // Use cached canonical path to avoid repeated filesystem calls
32        let file_path = self.get_canonical_path(file_path);
33
34        debug!("Analyzing file: {:?}", file_path);
35
36        // Cache the file content for later use (e.g., in find_fixture_definition)
37        // Use Arc for efficient sharing without cloning
38        self.file_cache
39            .insert(file_path.clone(), std::sync::Arc::new(content.to_string()));
40
41        // Parse the Python code
42        let parsed = match parse(content, Mode::Module, "") {
43            Ok(ast) => ast,
44            Err(e) => {
45                error!("Failed to parse Python file {:?}: {}", file_path, e);
46                return;
47            }
48        };
49
50        // Clear previous usages for this file
51        self.usages.remove(&file_path);
52
53        // Clear previous undeclared fixtures for this file
54        self.undeclared_fixtures.remove(&file_path);
55
56        // Clear previous imports for this file
57        self.imports.remove(&file_path);
58
59        // Note: line_index_cache uses content-hash-based invalidation,
60        // so we don't need to clear it here - get_line_index will detect
61        // if the content has changed and rebuild if necessary.
62
63        // Clear previous fixture definitions from this file (only when re-analyzing)
64        // Skip this during initial workspace scan for performance
65        if cleanup_previous {
66            self.cleanup_definitions_for_file(&file_path);
67        }
68
69        // Check if this is a conftest.py
70        let is_conftest = file_path
71            .file_name()
72            .map(|n| n == "conftest.py")
73            .unwrap_or(false);
74        debug!("is_conftest: {}", is_conftest);
75
76        // Get or build line index for O(1) line lookups (cached for performance)
77        let line_index = self.get_line_index(&file_path, content);
78
79        // Process each statement in the module
80        if let rustpython_parser::ast::Mod::Module(module) = parsed {
81            debug!("Module has {} statements", module.body.len());
82
83            // First pass: collect all module-level names (imports, assignments, function/class defs)
84            let mut module_level_names = HashSet::new();
85            for stmt in &module.body {
86                self.collect_module_level_names(stmt, &mut module_level_names);
87            }
88            self.imports.insert(file_path.clone(), module_level_names);
89
90            // Second pass: analyze fixtures and tests
91            for stmt in &module.body {
92                self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
93            }
94        }
95
96        debug!("Analysis complete for {:?}", file_path);
97    }
98
99    /// Remove definitions that were in a specific file.
100    /// Uses the file_definitions reverse index for efficient O(m) cleanup
101    /// where m = number of fixtures in this file, rather than O(n) where
102    /// n = total number of unique fixture names.
103    ///
104    /// Deadlock-free design:
105    /// 1. Atomically remove the set of fixture names from file_definitions
106    /// 2. For each fixture name, get a mutable reference, modify, then drop
107    /// 3. Only after dropping the reference, remove empty entries
108    fn cleanup_definitions_for_file(&self, file_path: &PathBuf) {
109        // Step 1: Atomically remove and get the fixture names for this file
110        let fixture_names = match self.file_definitions.remove(file_path) {
111            Some((_, names)) => names,
112            None => return, // No fixtures defined in this file
113        };
114
115        // Step 2: For each fixture name, remove definitions from this file
116        for fixture_name in fixture_names {
117            let should_remove = {
118                // Get mutable reference, modify in place, check if empty
119                if let Some(mut defs) = self.definitions.get_mut(&fixture_name) {
120                    defs.retain(|def| def.file_path != *file_path);
121                    defs.is_empty()
122                } else {
123                    false
124                }
125            }; // RefMut dropped here - safe to call remove_if now
126
127            // Step 3: Remove empty entries atomically
128            if should_remove {
129                // Use remove_if to ensure we only remove if still empty
130                // (another thread might have added a definition)
131                self.definitions
132                    .remove_if(&fixture_name, |_, defs| defs.is_empty());
133            }
134        }
135    }
136
137    /// Build an index of line start offsets for O(1) line number lookups
138    pub(crate) fn build_line_index(content: &str) -> Vec<usize> {
139        let mut line_index = Vec::with_capacity(content.len() / 30);
140        line_index.push(0);
141        for (i, c) in content.char_indices() {
142            if c == '\n' {
143                line_index.push(i + 1);
144            }
145        }
146        line_index
147    }
148
149    /// Get line number (1-based) from byte offset
150    pub(crate) fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
151        match line_index.binary_search(&offset) {
152            Ok(line) => line + 1,
153            Err(line) => line,
154        }
155    }
156
157    /// Get character position within a line from byte offset
158    pub(crate) fn get_char_position_from_offset(
159        &self,
160        offset: usize,
161        line_index: &[usize],
162    ) -> usize {
163        let line = self.get_line_from_offset(offset, line_index);
164        let line_start = line_index[line - 1];
165        offset.saturating_sub(line_start)
166    }
167
168    /// Returns an iterator over all function arguments including positional-only,
169    /// regular positional, and keyword-only arguments.
170    /// This is needed because pytest fixtures can be declared as any of these types.
171    pub(crate) fn all_args(args: &Arguments) -> impl Iterator<Item = &ArgWithDefault> {
172        args.posonlyargs
173            .iter()
174            .chain(args.args.iter())
175            .chain(args.kwonlyargs.iter())
176    }
177
178    /// Helper to record a fixture usage in the database.
179    /// Reduces code duplication across multiple call sites.
180    fn record_fixture_usage(
181        &self,
182        file_path: &Path,
183        fixture_name: String,
184        line: usize,
185        start_char: usize,
186        end_char: usize,
187    ) {
188        let file_path_buf = file_path.to_path_buf();
189        let usage = FixtureUsage {
190            name: fixture_name,
191            file_path: file_path_buf.clone(),
192            line,
193            start_char,
194            end_char,
195        };
196        self.usages.entry(file_path_buf).or_default().push(usage);
197    }
198
199    /// Helper to record a fixture definition in the database.
200    /// Also maintains the file_definitions reverse index for efficient cleanup.
201    fn record_fixture_definition(&self, definition: FixtureDefinition) {
202        let file_path = definition.file_path.clone();
203        let fixture_name = definition.name.clone();
204
205        // Add to main definitions map
206        self.definitions
207            .entry(fixture_name.clone())
208            .or_default()
209            .push(definition);
210
211        // Maintain reverse index for efficient cleanup
212        self.file_definitions
213            .entry(file_path)
214            .or_default()
215            .insert(fixture_name);
216    }
217
218    /// Visit a statement and extract fixture definitions and usages
219    fn visit_stmt(
220        &self,
221        stmt: &Stmt,
222        file_path: &PathBuf,
223        _is_conftest: bool,
224        content: &str,
225        line_index: &[usize],
226    ) {
227        // First check for assignment-style fixtures: fixture_name = pytest.fixture()(func)
228        if let Stmt::Assign(assign) = stmt {
229            self.visit_assignment_fixture(assign, file_path, content, line_index);
230        }
231
232        // Handle class definitions - recurse into class body to find test methods
233        if let Stmt::ClassDef(class_def) = stmt {
234            // Check for @pytest.mark.usefixtures decorator on the class
235            for decorator in &class_def.decorator_list {
236                let usefixtures = decorators::extract_usefixtures_names(decorator);
237                for (fixture_name, range) in usefixtures {
238                    let usage_line =
239                        self.get_line_from_offset(range.start().to_usize(), line_index);
240                    let start_char =
241                        self.get_char_position_from_offset(range.start().to_usize(), line_index);
242                    let end_char =
243                        self.get_char_position_from_offset(range.end().to_usize(), line_index);
244
245                    info!(
246                        "Found usefixtures usage on class: {} at {:?}:{}:{}",
247                        fixture_name, file_path, usage_line, start_char
248                    );
249
250                    self.record_fixture_usage(
251                        file_path,
252                        fixture_name,
253                        usage_line,
254                        start_char + 1,
255                        end_char - 1,
256                    );
257                }
258            }
259
260            for class_stmt in &class_def.body {
261                self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
262            }
263            return;
264        }
265
266        // Handle both regular and async function definitions
267        let (func_name, decorator_list, args, range, body, returns) = match stmt {
268            Stmt::FunctionDef(func_def) => (
269                func_def.name.as_str(),
270                &func_def.decorator_list,
271                &func_def.args,
272                func_def.range,
273                &func_def.body,
274                &func_def.returns,
275            ),
276            Stmt::AsyncFunctionDef(func_def) => (
277                func_def.name.as_str(),
278                &func_def.decorator_list,
279                &func_def.args,
280                func_def.range,
281                &func_def.body,
282                &func_def.returns,
283            ),
284            _ => return,
285        };
286
287        debug!("Found function: {}", func_name);
288
289        // Check for @pytest.mark.usefixtures decorator on the function
290        for decorator in decorator_list {
291            let usefixtures = decorators::extract_usefixtures_names(decorator);
292            for (fixture_name, range) in usefixtures {
293                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
294                let start_char =
295                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
296                let end_char =
297                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
298
299                info!(
300                    "Found usefixtures usage on function: {} at {:?}:{}:{}",
301                    fixture_name, file_path, usage_line, start_char
302                );
303
304                self.record_fixture_usage(
305                    file_path,
306                    fixture_name,
307                    usage_line,
308                    start_char + 1,
309                    end_char - 1,
310                );
311            }
312        }
313
314        // Check for @pytest.mark.parametrize with indirect=True on the function
315        for decorator in decorator_list {
316            let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
317            for (fixture_name, range) in indirect_fixtures {
318                let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
319                let start_char =
320                    self.get_char_position_from_offset(range.start().to_usize(), line_index);
321                let end_char =
322                    self.get_char_position_from_offset(range.end().to_usize(), line_index);
323
324                info!(
325                    "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
326                    fixture_name, file_path, usage_line, start_char
327                );
328
329                self.record_fixture_usage(
330                    file_path,
331                    fixture_name,
332                    usage_line,
333                    start_char + 1,
334                    end_char - 1,
335                );
336            }
337        }
338
339        // Check if this is a fixture definition
340        debug!(
341            "Function {} has {} decorators",
342            func_name,
343            decorator_list.len()
344        );
345        let fixture_decorator = decorator_list
346            .iter()
347            .find(|dec| decorators::is_fixture_decorator(dec));
348
349        if let Some(decorator) = fixture_decorator {
350            debug!("  Decorator matched as fixture!");
351
352            // Check if the fixture has a custom name
353            let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
354                .unwrap_or_else(|| func_name.to_string());
355
356            let line = self.get_line_from_offset(range.start().to_usize(), line_index);
357            let docstring = self.extract_docstring(body);
358            let return_type = self.extract_return_type(returns, body, content);
359
360            info!(
361                "Found fixture definition: {} (function: {}) at {:?}:{}",
362                fixture_name, func_name, file_path, line
363            );
364
365            let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
366
367            let is_third_party = file_path.to_string_lossy().contains("site-packages");
368            let definition = FixtureDefinition {
369                name: fixture_name.clone(),
370                file_path: file_path.clone(),
371                line,
372                start_char,
373                end_char,
374                docstring,
375                return_type,
376                is_third_party,
377            };
378
379            self.record_fixture_definition(definition);
380
381            // Fixtures can depend on other fixtures - record these as usages too
382            let mut declared_params: HashSet<String> = HashSet::new();
383            declared_params.insert("self".to_string());
384            declared_params.insert("request".to_string());
385            declared_params.insert(func_name.to_string());
386
387            for arg in Self::all_args(args) {
388                let arg_name = arg.def.arg.as_str();
389                declared_params.insert(arg_name.to_string());
390
391                if arg_name != "self" && arg_name != "request" {
392                    let arg_line =
393                        self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
394                    let start_char = self.get_char_position_from_offset(
395                        arg.def.range.start().to_usize(),
396                        line_index,
397                    );
398                    let end_char = self
399                        .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
400
401                    info!(
402                        "Found fixture dependency: {} at {:?}:{}:{}",
403                        arg_name, file_path, arg_line, start_char
404                    );
405
406                    self.record_fixture_usage(
407                        file_path,
408                        arg_name.to_string(),
409                        arg_line,
410                        start_char,
411                        end_char,
412                    );
413                }
414            }
415
416            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
417            self.scan_function_body_for_undeclared_fixtures(
418                body,
419                file_path,
420                line_index,
421                &declared_params,
422                func_name,
423                function_line,
424            );
425        }
426
427        // Check if this is a test function
428        let is_test = func_name.starts_with("test_");
429
430        if is_test {
431            debug!("Found test function: {}", func_name);
432
433            let mut declared_params: HashSet<String> = HashSet::new();
434            declared_params.insert("self".to_string());
435            declared_params.insert("request".to_string());
436
437            for arg in Self::all_args(args) {
438                let arg_name = arg.def.arg.as_str();
439                declared_params.insert(arg_name.to_string());
440
441                if arg_name != "self" {
442                    let arg_offset = arg.def.range.start().to_usize();
443                    let arg_line = self.get_line_from_offset(arg_offset, line_index);
444                    let start_char = self.get_char_position_from_offset(arg_offset, line_index);
445                    let end_char = self
446                        .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
447
448                    debug!(
449                        "Parameter {} at offset {}, calculated line {}, char {}",
450                        arg_name, arg_offset, arg_line, start_char
451                    );
452                    info!(
453                        "Found fixture usage: {} at {:?}:{}:{}",
454                        arg_name, file_path, arg_line, start_char
455                    );
456
457                    self.record_fixture_usage(
458                        file_path,
459                        arg_name.to_string(),
460                        arg_line,
461                        start_char,
462                        end_char,
463                    );
464                }
465            }
466
467            let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
468            self.scan_function_body_for_undeclared_fixtures(
469                body,
470                file_path,
471                line_index,
472                &declared_params,
473                func_name,
474                function_line,
475            );
476        }
477    }
478
479    /// Handle assignment-style fixtures: fixture_name = pytest.fixture()(func)
480    fn visit_assignment_fixture(
481        &self,
482        assign: &rustpython_parser::ast::StmtAssign,
483        file_path: &PathBuf,
484        _content: &str,
485        line_index: &[usize],
486    ) {
487        if let Expr::Call(outer_call) = &*assign.value {
488            if let Expr::Call(inner_call) = &*outer_call.func {
489                if decorators::is_fixture_decorator(&inner_call.func) {
490                    for target in &assign.targets {
491                        if let Expr::Name(name) = target {
492                            let fixture_name = name.id.as_str();
493                            let line = self
494                                .get_line_from_offset(assign.range.start().to_usize(), line_index);
495
496                            let start_char = self.get_char_position_from_offset(
497                                name.range.start().to_usize(),
498                                line_index,
499                            );
500                            let end_char = self.get_char_position_from_offset(
501                                name.range.end().to_usize(),
502                                line_index,
503                            );
504
505                            info!(
506                                "Found fixture assignment: {} at {:?}:{}:{}-{}",
507                                fixture_name, file_path, line, start_char, end_char
508                            );
509
510                            let is_third_party =
511                                file_path.to_string_lossy().contains("site-packages");
512                            let definition = FixtureDefinition {
513                                name: fixture_name.to_string(),
514                                file_path: file_path.clone(),
515                                line,
516                                start_char,
517                                end_char,
518                                docstring: None,
519                                return_type: None,
520                                is_third_party,
521                            };
522
523                            self.record_fixture_definition(definition);
524                        }
525                    }
526                }
527            }
528        }
529    }
530}
531
532// Second impl block for additional analyzer methods
533impl FixtureDatabase {
534    // ============ Module-level name collection ============
535
536    /// Collect all module-level names (imports, assignments, function/class defs)
537    fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
538        match stmt {
539            Stmt::Import(import_stmt) => {
540                for alias in &import_stmt.names {
541                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
542                    names.insert(name.to_string());
543                }
544            }
545            Stmt::ImportFrom(import_from) => {
546                for alias in &import_from.names {
547                    let name = alias.asname.as_ref().unwrap_or(&alias.name);
548                    names.insert(name.to_string());
549                }
550            }
551            Stmt::FunctionDef(func_def) => {
552                let is_fixture = func_def
553                    .decorator_list
554                    .iter()
555                    .any(decorators::is_fixture_decorator);
556                if !is_fixture {
557                    names.insert(func_def.name.to_string());
558                }
559            }
560            Stmt::AsyncFunctionDef(func_def) => {
561                let is_fixture = func_def
562                    .decorator_list
563                    .iter()
564                    .any(decorators::is_fixture_decorator);
565                if !is_fixture {
566                    names.insert(func_def.name.to_string());
567                }
568            }
569            Stmt::ClassDef(class_def) => {
570                names.insert(class_def.name.to_string());
571            }
572            Stmt::Assign(assign) => {
573                for target in &assign.targets {
574                    self.collect_names_from_expr(target, names);
575                }
576            }
577            Stmt::AnnAssign(ann_assign) => {
578                self.collect_names_from_expr(&ann_assign.target, names);
579            }
580            _ => {}
581        }
582    }
583
584    #[allow(clippy::only_used_in_recursion)]
585    pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
586        match expr {
587            Expr::Name(name) => {
588                names.insert(name.id.to_string());
589            }
590            Expr::Tuple(tuple) => {
591                for elt in &tuple.elts {
592                    self.collect_names_from_expr(elt, names);
593                }
594            }
595            Expr::List(list) => {
596                for elt in &list.elts {
597                    self.collect_names_from_expr(elt, names);
598                }
599            }
600            _ => {}
601        }
602    }
603
604    // Docstring and return type extraction methods are in docstring.rs
605
606    /// Find the character position of a function name in a line
607    fn find_function_name_position(
608        &self,
609        content: &str,
610        line: usize,
611        func_name: &str,
612    ) -> (usize, usize) {
613        super::string_utils::find_function_name_position(content, line, func_name)
614    }
615}
616
617// Undeclared fixtures scanning methods are in undeclared.rs