1use 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, 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: decorators::extract_fixture_scope(&outer_call.func)
635 .unwrap_or_default(),
636 yield_line: None, autouse: false, };
639
640 self.record_fixture_definition(definition);
641 }
642 }
643 }
644 }
645 }
646 }
647
648 fn visit_pytestmark_assignment(
656 &self,
657 value: Option<&Expr>,
658 file_path: &PathBuf,
659 line_index: &[usize],
660 ) {
661 let Some(value) = value else {
662 return;
663 };
664
665 let usefixtures = decorators::extract_usefixtures_from_expr(value);
666 for (fixture_name, range) in usefixtures {
667 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
668 let start_char =
669 self.get_char_position_from_offset(range.start().to_usize(), line_index);
670 let end_char = self.get_char_position_from_offset(range.end().to_usize(), line_index);
671
672 info!(
673 "Found usefixtures usage via pytestmark assignment: {} at {:?}:{}:{}",
674 fixture_name, file_path, usage_line, start_char
675 );
676
677 self.record_fixture_usage(
678 file_path,
679 fixture_name,
680 usage_line,
681 start_char.saturating_add(1),
682 end_char.saturating_sub(1),
683 );
684 }
685 }
686}
687
688impl FixtureDatabase {
690 fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
694 match stmt {
695 Stmt::Import(import_stmt) => {
696 for alias in &import_stmt.names {
697 let name = alias.asname.as_ref().unwrap_or(&alias.name);
698 names.insert(name.to_string());
699 }
700 }
701 Stmt::ImportFrom(import_from) => {
702 for alias in &import_from.names {
703 let name = alias.asname.as_ref().unwrap_or(&alias.name);
704 names.insert(name.to_string());
705 }
706 }
707 Stmt::FunctionDef(func_def) => {
708 let is_fixture = func_def
709 .decorator_list
710 .iter()
711 .any(decorators::is_fixture_decorator);
712 if !is_fixture {
713 names.insert(func_def.name.to_string());
714 }
715 }
716 Stmt::AsyncFunctionDef(func_def) => {
717 let is_fixture = func_def
718 .decorator_list
719 .iter()
720 .any(decorators::is_fixture_decorator);
721 if !is_fixture {
722 names.insert(func_def.name.to_string());
723 }
724 }
725 Stmt::ClassDef(class_def) => {
726 names.insert(class_def.name.to_string());
727 }
728 Stmt::Assign(assign) => {
729 for target in &assign.targets {
730 self.collect_names_from_expr(target, names);
731 }
732 }
733 Stmt::AnnAssign(ann_assign) => {
734 self.collect_names_from_expr(&ann_assign.target, names);
735 }
736 _ => {}
737 }
738 }
739
740 #[allow(clippy::only_used_in_recursion)]
741 pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
742 match expr {
743 Expr::Name(name) => {
744 names.insert(name.id.to_string());
745 }
746 Expr::Tuple(tuple) => {
747 for elt in &tuple.elts {
748 self.collect_names_from_expr(elt, names);
749 }
750 }
751 Expr::List(list) => {
752 for elt in &list.elts {
753 self.collect_names_from_expr(elt, names);
754 }
755 }
756 _ => {}
757 }
758 }
759
760 fn find_function_name_position(
764 &self,
765 content: &str,
766 line: usize,
767 func_name: &str,
768 ) -> (usize, usize) {
769 super::string_utils::find_function_name_position(content, line, func_name)
770 }
771
772 fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
775 for stmt in body {
776 if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
777 return Some(line);
778 }
779 }
780 None
781 }
782
783 fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
785 match stmt {
786 Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
787 Stmt::If(if_stmt) => {
788 for s in &if_stmt.body {
790 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
791 return Some(line);
792 }
793 }
794 for s in &if_stmt.orelse {
796 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
797 return Some(line);
798 }
799 }
800 None
801 }
802 Stmt::With(with_stmt) => {
803 for s in &with_stmt.body {
804 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
805 return Some(line);
806 }
807 }
808 None
809 }
810 Stmt::AsyncWith(with_stmt) => {
811 for s in &with_stmt.body {
812 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
813 return Some(line);
814 }
815 }
816 None
817 }
818 Stmt::Try(try_stmt) => {
819 for s in &try_stmt.body {
820 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
821 return Some(line);
822 }
823 }
824 for handler in &try_stmt.handlers {
825 let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
826 for s in &h.body {
827 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
828 return Some(line);
829 }
830 }
831 }
832 for s in &try_stmt.orelse {
833 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
834 return Some(line);
835 }
836 }
837 for s in &try_stmt.finalbody {
838 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
839 return Some(line);
840 }
841 }
842 None
843 }
844 Stmt::For(for_stmt) => {
845 for s in &for_stmt.body {
846 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
847 return Some(line);
848 }
849 }
850 for s in &for_stmt.orelse {
851 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
852 return Some(line);
853 }
854 }
855 None
856 }
857 Stmt::AsyncFor(for_stmt) => {
858 for s in &for_stmt.body {
859 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
860 return Some(line);
861 }
862 }
863 for s in &for_stmt.orelse {
864 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
865 return Some(line);
866 }
867 }
868 None
869 }
870 Stmt::While(while_stmt) => {
871 for s in &while_stmt.body {
872 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
873 return Some(line);
874 }
875 }
876 for s in &while_stmt.orelse {
877 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
878 return Some(line);
879 }
880 }
881 None
882 }
883 _ => None,
884 }
885 }
886
887 fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
889 match expr {
890 Expr::Yield(yield_expr) => {
891 let line =
892 self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
893 Some(line)
894 }
895 Expr::YieldFrom(yield_from) => {
896 let line =
897 self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
898 Some(line)
899 }
900 _ => None,
901 }
902 }
903}
904
905