1use super::decorators;
8use super::types::{FixtureDefinition, FixtureScope, FixtureUsage};
9use super::FixtureDatabase;
10use rustpython_parser::ast::{ArgWithDefault, Arguments, Expr, Stmt};
11use rustpython_parser::{parse, Mode};
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use tracing::{debug, info};
15
16impl FixtureDatabase {
17 pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
20 self.analyze_file_internal(file_path, content, true);
21 }
22
23 pub(crate) fn analyze_file_fresh(&self, file_path: PathBuf, content: &str) {
26 self.analyze_file_internal(file_path, content, false);
27 }
28
29 fn analyze_file_internal(&self, file_path: PathBuf, content: &str, cleanup_previous: bool) {
31 let file_path = self.get_canonical_path(file_path);
33
34 debug!("Analyzing file: {:?}", file_path);
35
36 self.file_cache
39 .insert(file_path.clone(), std::sync::Arc::new(content.to_string()));
40
41 let parsed = match parse(content, Mode::Module, "") {
43 Ok(ast) => ast,
44 Err(e) => {
45 debug!(
48 "Failed to parse Python file {:?}: {} - keeping previous data",
49 file_path, e
50 );
51 return;
52 }
53 };
54
55 self.cleanup_usages_for_file(&file_path);
57 self.usages.remove(&file_path);
58
59 self.undeclared_fixtures.remove(&file_path);
61
62 self.imports.remove(&file_path);
64
65 if cleanup_previous {
72 self.cleanup_definitions_for_file(&file_path);
73 }
74
75 let is_conftest = file_path
77 .file_name()
78 .map(|n| n == "conftest.py")
79 .unwrap_or(false);
80 debug!("is_conftest: {}", is_conftest);
81
82 let line_index = self.get_line_index(&file_path, content);
84
85 if let rustpython_parser::ast::Mod::Module(module) = parsed {
87 debug!("Module has {} statements", module.body.len());
88
89 let mut module_level_names = HashSet::new();
91 for stmt in &module.body {
92 self.collect_module_level_names(stmt, &mut module_level_names);
93 }
94 self.imports.insert(file_path.clone(), module_level_names);
95
96 for stmt in &module.body {
98 self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
99 }
100 }
101
102 debug!("Analysis complete for {:?}", file_path);
103
104 self.evict_cache_if_needed();
106 }
107
108 fn cleanup_definitions_for_file(&self, file_path: &PathBuf) {
118 let fixture_names = match self.file_definitions.remove(file_path) {
120 Some((_, names)) => names,
121 None => return, };
123
124 for fixture_name in fixture_names {
126 let should_remove = {
127 if let Some(mut defs) = self.definitions.get_mut(&fixture_name) {
129 defs.retain(|def| def.file_path != *file_path);
130 defs.is_empty()
131 } else {
132 false
133 }
134 }; if should_remove {
138 self.definitions
141 .remove_if(&fixture_name, |_, defs| defs.is_empty());
142 }
143 }
144 }
145
146 fn cleanup_usages_for_file(&self, file_path: &PathBuf) {
152 let all_keys: Vec<String> = self
154 .usage_by_fixture
155 .iter()
156 .map(|entry| entry.key().clone())
157 .collect();
158
159 for fixture_name in all_keys {
161 let should_remove = {
162 if let Some(mut usages) = self.usage_by_fixture.get_mut(&fixture_name) {
163 let had_usages = usages.iter().any(|(path, _)| path == file_path);
164 if had_usages {
165 usages.retain(|(path, _)| path != file_path);
166 }
167 usages.is_empty()
168 } else {
169 false
170 }
171 };
172
173 if should_remove {
174 self.usage_by_fixture
175 .remove_if(&fixture_name, |_, usages| usages.is_empty());
176 }
177 }
178 }
179
180 pub(crate) fn build_line_index(content: &str) -> Vec<usize> {
183 let bytes = content.as_bytes();
184 let mut line_index = Vec::with_capacity(content.len() / 30);
185 line_index.push(0);
186 for i in memchr::memchr_iter(b'\n', bytes) {
187 line_index.push(i + 1);
188 }
189 line_index
190 }
191
192 pub(crate) fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
194 match line_index.binary_search(&offset) {
195 Ok(line) => line + 1,
196 Err(line) => line,
197 }
198 }
199
200 pub(crate) fn get_char_position_from_offset(
202 &self,
203 offset: usize,
204 line_index: &[usize],
205 ) -> usize {
206 let line = self.get_line_from_offset(offset, line_index);
207 let line_start = line_index[line - 1];
208 offset.saturating_sub(line_start)
209 }
210
211 pub(crate) fn all_args(args: &Arguments) -> impl Iterator<Item = &ArgWithDefault> {
215 args.posonlyargs
216 .iter()
217 .chain(args.args.iter())
218 .chain(args.kwonlyargs.iter())
219 }
220
221 fn record_fixture_usage(
225 &self,
226 file_path: &Path,
227 fixture_name: String,
228 line: usize,
229 start_char: usize,
230 end_char: usize,
231 ) {
232 let file_path_buf = file_path.to_path_buf();
233 let usage = FixtureUsage {
234 name: fixture_name.clone(),
235 file_path: file_path_buf.clone(),
236 line,
237 start_char,
238 end_char,
239 };
240
241 self.usages
243 .entry(file_path_buf.clone())
244 .or_default()
245 .push(usage.clone());
246
247 self.usage_by_fixture
249 .entry(fixture_name)
250 .or_default()
251 .push((file_path_buf, usage));
252 }
253
254 fn record_fixture_definition(&self, definition: FixtureDefinition) {
257 let file_path = definition.file_path.clone();
258 let fixture_name = definition.name.clone();
259
260 self.definitions
262 .entry(fixture_name.clone())
263 .or_default()
264 .push(definition);
265
266 self.file_definitions
268 .entry(file_path)
269 .or_default()
270 .insert(fixture_name);
271
272 self.invalidate_cycle_cache();
274 }
275
276 fn visit_stmt(
278 &self,
279 stmt: &Stmt,
280 file_path: &PathBuf,
281 _is_conftest: bool,
282 content: &str,
283 line_index: &[usize],
284 ) {
285 if let Stmt::Assign(assign) = stmt {
287 self.visit_assignment_fixture(assign, file_path, content, line_index);
288
289 let is_pytestmark = assign.targets.iter().any(
292 |target| matches!(target, Expr::Name(name) if name.id.as_str() == "pytestmark"),
293 );
294 if is_pytestmark {
295 self.visit_pytestmark_assignment(Some(&assign.value), file_path, line_index);
296 }
297 }
298
299 if let Stmt::AnnAssign(ann_assign) = stmt {
301 let is_pytestmark = matches!(
302 ann_assign.target.as_ref(),
303 Expr::Name(name) if name.id.as_str() == "pytestmark"
304 );
305 if is_pytestmark {
306 self.visit_pytestmark_assignment(
307 ann_assign.value.as_deref(),
308 file_path,
309 line_index,
310 );
311 }
312 }
313
314 if let Stmt::ClassDef(class_def) = stmt {
316 for decorator in &class_def.decorator_list {
318 let usefixtures = decorators::extract_usefixtures_names(decorator);
319 for (fixture_name, range) in usefixtures {
320 let usage_line =
321 self.get_line_from_offset(range.start().to_usize(), line_index);
322 let start_char =
323 self.get_char_position_from_offset(range.start().to_usize(), line_index);
324 let end_char =
325 self.get_char_position_from_offset(range.end().to_usize(), line_index);
326
327 info!(
328 "Found usefixtures usage on class: {} at {:?}:{}:{}",
329 fixture_name, file_path, usage_line, start_char
330 );
331
332 self.record_fixture_usage(
333 file_path,
334 fixture_name,
335 usage_line,
336 start_char + 1,
337 end_char - 1,
338 );
339 }
340 }
341
342 for class_stmt in &class_def.body {
343 self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
344 }
345 return;
346 }
347
348 let (func_name, decorator_list, args, range, body, returns) = match stmt {
350 Stmt::FunctionDef(func_def) => (
351 func_def.name.as_str(),
352 &func_def.decorator_list,
353 &func_def.args,
354 func_def.range,
355 &func_def.body,
356 &func_def.returns,
357 ),
358 Stmt::AsyncFunctionDef(func_def) => (
359 func_def.name.as_str(),
360 &func_def.decorator_list,
361 &func_def.args,
362 func_def.range,
363 &func_def.body,
364 &func_def.returns,
365 ),
366 _ => return,
367 };
368
369 debug!("Found function: {}", func_name);
370
371 for decorator in decorator_list {
373 let usefixtures = decorators::extract_usefixtures_names(decorator);
374 for (fixture_name, range) in usefixtures {
375 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
376 let start_char =
377 self.get_char_position_from_offset(range.start().to_usize(), line_index);
378 let end_char =
379 self.get_char_position_from_offset(range.end().to_usize(), line_index);
380
381 info!(
382 "Found usefixtures usage on function: {} at {:?}:{}:{}",
383 fixture_name, file_path, usage_line, start_char
384 );
385
386 self.record_fixture_usage(
387 file_path,
388 fixture_name,
389 usage_line,
390 start_char + 1,
391 end_char - 1,
392 );
393 }
394 }
395
396 for decorator in decorator_list {
398 let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
399 for (fixture_name, range) in indirect_fixtures {
400 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
401 let start_char =
402 self.get_char_position_from_offset(range.start().to_usize(), line_index);
403 let end_char =
404 self.get_char_position_from_offset(range.end().to_usize(), line_index);
405
406 info!(
407 "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
408 fixture_name, file_path, usage_line, start_char
409 );
410
411 self.record_fixture_usage(
412 file_path,
413 fixture_name,
414 usage_line,
415 start_char + 1,
416 end_char - 1,
417 );
418 }
419 }
420
421 debug!(
423 "Function {} has {} decorators",
424 func_name,
425 decorator_list.len()
426 );
427 let fixture_decorator = decorator_list
428 .iter()
429 .find(|dec| decorators::is_fixture_decorator(dec));
430
431 if let Some(decorator) = fixture_decorator {
432 debug!(" Decorator matched as fixture!");
433
434 let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
436 .unwrap_or_else(|| func_name.to_string());
437
438 let scope = decorators::extract_fixture_scope(decorator).unwrap_or_default();
440 let autouse = decorators::extract_fixture_autouse(decorator);
441
442 let line = self.get_line_from_offset(range.start().to_usize(), line_index);
443 let docstring = self.extract_docstring(body);
444 let return_type = self.extract_return_type(returns, body, content);
445
446 info!(
447 "Found fixture definition: {} (function: {}, scope: {:?}) at {:?}:{}",
448 fixture_name, func_name, scope, file_path, line
449 );
450
451 let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
452
453 let is_third_party = file_path.to_string_lossy().contains("site-packages")
454 || self.is_editable_install_third_party(file_path);
455 let is_plugin = self.plugin_fixture_files.contains_key(file_path);
456
457 let mut declared_params: HashSet<String> = HashSet::new();
459 let mut dependencies: Vec<String> = Vec::new();
460 declared_params.insert("self".to_string());
461 declared_params.insert("request".to_string());
462 declared_params.insert(func_name.to_string());
463
464 for arg in Self::all_args(args) {
465 let arg_name = arg.def.arg.as_str();
466 declared_params.insert(arg_name.to_string());
467 if arg_name != "self" && arg_name != "request" {
469 dependencies.push(arg_name.to_string());
470 }
471 }
472
473 let end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
475
476 let definition = FixtureDefinition {
477 name: fixture_name.clone(),
478 file_path: file_path.clone(),
479 line,
480 end_line,
481 start_char,
482 end_char,
483 docstring,
484 return_type,
485 is_third_party,
486 is_plugin,
487 dependencies: dependencies.clone(),
488 scope,
489 yield_line: self.find_yield_line(body, line_index),
490 autouse,
491 };
492
493 self.record_fixture_definition(definition);
494
495 for arg in Self::all_args(args) {
497 let arg_name = arg.def.arg.as_str();
498
499 if arg_name != "self" && arg_name != "request" {
500 let arg_line =
501 self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
502 let start_char = self.get_char_position_from_offset(
503 arg.def.range.start().to_usize(),
504 line_index,
505 );
506 let end_char = start_char + arg_name.len();
508
509 info!(
510 "Found fixture dependency: {} at {:?}:{}:{}",
511 arg_name, file_path, arg_line, start_char
512 );
513
514 self.record_fixture_usage(
515 file_path,
516 arg_name.to_string(),
517 arg_line,
518 start_char,
519 end_char,
520 );
521 }
522 }
523
524 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
525 self.scan_function_body_for_undeclared_fixtures(
526 body,
527 file_path,
528 line_index,
529 &declared_params,
530 func_name,
531 function_line,
532 );
533 }
534
535 let is_test = func_name.starts_with("test_");
537
538 if is_test {
539 debug!("Found test function: {}", func_name);
540
541 let mut declared_params: HashSet<String> = HashSet::new();
542 declared_params.insert("self".to_string());
543 declared_params.insert("request".to_string());
544
545 for arg in Self::all_args(args) {
546 let arg_name = arg.def.arg.as_str();
547 declared_params.insert(arg_name.to_string());
548
549 if arg_name != "self" {
550 let arg_offset = arg.def.range.start().to_usize();
551 let arg_line = self.get_line_from_offset(arg_offset, line_index);
552 let start_char = self.get_char_position_from_offset(arg_offset, line_index);
553 let end_char = start_char + arg_name.len();
555
556 debug!(
557 "Parameter {} at offset {}, calculated line {}, char {}",
558 arg_name, arg_offset, arg_line, start_char
559 );
560 info!(
561 "Found fixture usage: {} at {:?}:{}:{}",
562 arg_name, file_path, arg_line, start_char
563 );
564
565 self.record_fixture_usage(
566 file_path,
567 arg_name.to_string(),
568 arg_line,
569 start_char,
570 end_char,
571 );
572 }
573 }
574
575 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
576 self.scan_function_body_for_undeclared_fixtures(
577 body,
578 file_path,
579 line_index,
580 &declared_params,
581 func_name,
582 function_line,
583 );
584 }
585 }
586
587 fn visit_assignment_fixture(
589 &self,
590 assign: &rustpython_parser::ast::StmtAssign,
591 file_path: &PathBuf,
592 _content: &str,
593 line_index: &[usize],
594 ) {
595 if let Expr::Call(outer_call) = &*assign.value {
596 if let Expr::Call(inner_call) = &*outer_call.func {
597 if decorators::is_fixture_decorator(&inner_call.func) {
598 for target in &assign.targets {
599 if let Expr::Name(name) = target {
600 let fixture_name = name.id.as_str();
601 let line = self
602 .get_line_from_offset(assign.range.start().to_usize(), line_index);
603
604 let start_char = self.get_char_position_from_offset(
605 name.range.start().to_usize(),
606 line_index,
607 );
608 let end_char = self.get_char_position_from_offset(
609 name.range.end().to_usize(),
610 line_index,
611 );
612
613 info!(
614 "Found fixture assignment: {} at {:?}:{}:{}-{}",
615 fixture_name, file_path, line, start_char, end_char
616 );
617
618 let is_third_party =
619 file_path.to_string_lossy().contains("site-packages")
620 || self.is_editable_install_third_party(file_path);
621 let is_plugin = self.plugin_fixture_files.contains_key(file_path);
622 let definition = FixtureDefinition {
623 name: fixture_name.to_string(),
624 file_path: file_path.clone(),
625 line,
626 end_line: line, start_char,
628 end_char,
629 docstring: None,
630 return_type: None,
631 is_third_party,
632 is_plugin,
633 dependencies: Vec::new(), scope: FixtureScope::default(), yield_line: None, autouse: false, };
638
639 self.record_fixture_definition(definition);
640 }
641 }
642 }
643 }
644 }
645 }
646
647 fn visit_pytestmark_assignment(
655 &self,
656 value: Option<&Expr>,
657 file_path: &PathBuf,
658 line_index: &[usize],
659 ) {
660 let Some(value) = value else {
661 return;
662 };
663
664 let usefixtures = decorators::extract_usefixtures_from_expr(value);
665 for (fixture_name, range) in usefixtures {
666 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
667 let start_char =
668 self.get_char_position_from_offset(range.start().to_usize(), line_index);
669 let end_char = self.get_char_position_from_offset(range.end().to_usize(), line_index);
670
671 info!(
672 "Found usefixtures usage via pytestmark assignment: {} at {:?}:{}:{}",
673 fixture_name, file_path, usage_line, start_char
674 );
675
676 self.record_fixture_usage(
677 file_path,
678 fixture_name,
679 usage_line,
680 start_char.saturating_add(1),
681 end_char.saturating_sub(1),
682 );
683 }
684 }
685}
686
687impl FixtureDatabase {
689 fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
693 match stmt {
694 Stmt::Import(import_stmt) => {
695 for alias in &import_stmt.names {
696 let name = alias.asname.as_ref().unwrap_or(&alias.name);
697 names.insert(name.to_string());
698 }
699 }
700 Stmt::ImportFrom(import_from) => {
701 for alias in &import_from.names {
702 let name = alias.asname.as_ref().unwrap_or(&alias.name);
703 names.insert(name.to_string());
704 }
705 }
706 Stmt::FunctionDef(func_def) => {
707 let is_fixture = func_def
708 .decorator_list
709 .iter()
710 .any(decorators::is_fixture_decorator);
711 if !is_fixture {
712 names.insert(func_def.name.to_string());
713 }
714 }
715 Stmt::AsyncFunctionDef(func_def) => {
716 let is_fixture = func_def
717 .decorator_list
718 .iter()
719 .any(decorators::is_fixture_decorator);
720 if !is_fixture {
721 names.insert(func_def.name.to_string());
722 }
723 }
724 Stmt::ClassDef(class_def) => {
725 names.insert(class_def.name.to_string());
726 }
727 Stmt::Assign(assign) => {
728 for target in &assign.targets {
729 self.collect_names_from_expr(target, names);
730 }
731 }
732 Stmt::AnnAssign(ann_assign) => {
733 self.collect_names_from_expr(&ann_assign.target, names);
734 }
735 _ => {}
736 }
737 }
738
739 #[allow(clippy::only_used_in_recursion)]
740 pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
741 match expr {
742 Expr::Name(name) => {
743 names.insert(name.id.to_string());
744 }
745 Expr::Tuple(tuple) => {
746 for elt in &tuple.elts {
747 self.collect_names_from_expr(elt, names);
748 }
749 }
750 Expr::List(list) => {
751 for elt in &list.elts {
752 self.collect_names_from_expr(elt, names);
753 }
754 }
755 _ => {}
756 }
757 }
758
759 fn find_function_name_position(
763 &self,
764 content: &str,
765 line: usize,
766 func_name: &str,
767 ) -> (usize, usize) {
768 super::string_utils::find_function_name_position(content, line, func_name)
769 }
770
771 fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
774 for stmt in body {
775 if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
776 return Some(line);
777 }
778 }
779 None
780 }
781
782 fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
784 match stmt {
785 Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
786 Stmt::If(if_stmt) => {
787 for s in &if_stmt.body {
789 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
790 return Some(line);
791 }
792 }
793 for s in &if_stmt.orelse {
795 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
796 return Some(line);
797 }
798 }
799 None
800 }
801 Stmt::With(with_stmt) => {
802 for s in &with_stmt.body {
803 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
804 return Some(line);
805 }
806 }
807 None
808 }
809 Stmt::AsyncWith(with_stmt) => {
810 for s in &with_stmt.body {
811 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
812 return Some(line);
813 }
814 }
815 None
816 }
817 Stmt::Try(try_stmt) => {
818 for s in &try_stmt.body {
819 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
820 return Some(line);
821 }
822 }
823 for handler in &try_stmt.handlers {
824 let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
825 for s in &h.body {
826 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
827 return Some(line);
828 }
829 }
830 }
831 for s in &try_stmt.orelse {
832 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
833 return Some(line);
834 }
835 }
836 for s in &try_stmt.finalbody {
837 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
838 return Some(line);
839 }
840 }
841 None
842 }
843 Stmt::For(for_stmt) => {
844 for s in &for_stmt.body {
845 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
846 return Some(line);
847 }
848 }
849 for s in &for_stmt.orelse {
850 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
851 return Some(line);
852 }
853 }
854 None
855 }
856 Stmt::AsyncFor(for_stmt) => {
857 for s in &for_stmt.body {
858 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
859 return Some(line);
860 }
861 }
862 for s in &for_stmt.orelse {
863 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
864 return Some(line);
865 }
866 }
867 None
868 }
869 Stmt::While(while_stmt) => {
870 for s in &while_stmt.body {
871 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
872 return Some(line);
873 }
874 }
875 for s in &while_stmt.orelse {
876 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
877 return Some(line);
878 }
879 }
880 None
881 }
882 _ => None,
883 }
884 }
885
886 fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
888 match expr {
889 Expr::Yield(yield_expr) => {
890 let line =
891 self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
892 Some(line)
893 }
894 Expr::YieldFrom(yield_from) => {
895 let line =
896 self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
897 Some(line)
898 }
899 _ => None,
900 }
901 }
902}
903
904