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
290 if let Stmt::ClassDef(class_def) = stmt {
292 for decorator in &class_def.decorator_list {
294 let usefixtures = decorators::extract_usefixtures_names(decorator);
295 for (fixture_name, range) in usefixtures {
296 let usage_line =
297 self.get_line_from_offset(range.start().to_usize(), line_index);
298 let start_char =
299 self.get_char_position_from_offset(range.start().to_usize(), line_index);
300 let end_char =
301 self.get_char_position_from_offset(range.end().to_usize(), line_index);
302
303 info!(
304 "Found usefixtures usage on class: {} at {:?}:{}:{}",
305 fixture_name, file_path, usage_line, start_char
306 );
307
308 self.record_fixture_usage(
309 file_path,
310 fixture_name,
311 usage_line,
312 start_char + 1,
313 end_char - 1,
314 );
315 }
316 }
317
318 for class_stmt in &class_def.body {
319 self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
320 }
321 return;
322 }
323
324 let (func_name, decorator_list, args, range, body, returns) = match stmt {
326 Stmt::FunctionDef(func_def) => (
327 func_def.name.as_str(),
328 &func_def.decorator_list,
329 &func_def.args,
330 func_def.range,
331 &func_def.body,
332 &func_def.returns,
333 ),
334 Stmt::AsyncFunctionDef(func_def) => (
335 func_def.name.as_str(),
336 &func_def.decorator_list,
337 &func_def.args,
338 func_def.range,
339 &func_def.body,
340 &func_def.returns,
341 ),
342 _ => return,
343 };
344
345 debug!("Found function: {}", func_name);
346
347 for decorator in decorator_list {
349 let usefixtures = decorators::extract_usefixtures_names(decorator);
350 for (fixture_name, range) in usefixtures {
351 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
352 let start_char =
353 self.get_char_position_from_offset(range.start().to_usize(), line_index);
354 let end_char =
355 self.get_char_position_from_offset(range.end().to_usize(), line_index);
356
357 info!(
358 "Found usefixtures usage on function: {} at {:?}:{}:{}",
359 fixture_name, file_path, usage_line, start_char
360 );
361
362 self.record_fixture_usage(
363 file_path,
364 fixture_name,
365 usage_line,
366 start_char + 1,
367 end_char - 1,
368 );
369 }
370 }
371
372 for decorator in decorator_list {
374 let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
375 for (fixture_name, range) in indirect_fixtures {
376 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
377 let start_char =
378 self.get_char_position_from_offset(range.start().to_usize(), line_index);
379 let end_char =
380 self.get_char_position_from_offset(range.end().to_usize(), line_index);
381
382 info!(
383 "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
384 fixture_name, file_path, usage_line, start_char
385 );
386
387 self.record_fixture_usage(
388 file_path,
389 fixture_name,
390 usage_line,
391 start_char + 1,
392 end_char - 1,
393 );
394 }
395 }
396
397 debug!(
399 "Function {} has {} decorators",
400 func_name,
401 decorator_list.len()
402 );
403 let fixture_decorator = decorator_list
404 .iter()
405 .find(|dec| decorators::is_fixture_decorator(dec));
406
407 if let Some(decorator) = fixture_decorator {
408 debug!(" Decorator matched as fixture!");
409
410 let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
412 .unwrap_or_else(|| func_name.to_string());
413
414 let scope = decorators::extract_fixture_scope(decorator).unwrap_or_default();
416
417 let line = self.get_line_from_offset(range.start().to_usize(), line_index);
418 let docstring = self.extract_docstring(body);
419 let return_type = self.extract_return_type(returns, body, content);
420
421 info!(
422 "Found fixture definition: {} (function: {}, scope: {:?}) at {:?}:{}",
423 fixture_name, func_name, scope, file_path, line
424 );
425
426 let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
427
428 let is_third_party = file_path.to_string_lossy().contains("site-packages");
429
430 let mut declared_params: HashSet<String> = HashSet::new();
432 let mut dependencies: Vec<String> = Vec::new();
433 declared_params.insert("self".to_string());
434 declared_params.insert("request".to_string());
435 declared_params.insert(func_name.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 if arg_name != "self" && arg_name != "request" {
442 dependencies.push(arg_name.to_string());
443 }
444 }
445
446 let end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
448
449 let definition = FixtureDefinition {
450 name: fixture_name.clone(),
451 file_path: file_path.clone(),
452 line,
453 end_line,
454 start_char,
455 end_char,
456 docstring,
457 return_type,
458 is_third_party,
459 dependencies: dependencies.clone(),
460 scope,
461 yield_line: self.find_yield_line(body, line_index),
462 };
463
464 self.record_fixture_definition(definition);
465
466 for arg in Self::all_args(args) {
468 let arg_name = arg.def.arg.as_str();
469
470 if arg_name != "self" && arg_name != "request" {
471 let arg_line =
472 self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
473 let start_char = self.get_char_position_from_offset(
474 arg.def.range.start().to_usize(),
475 line_index,
476 );
477 let end_char = start_char + arg_name.len();
479
480 info!(
481 "Found fixture dependency: {} at {:?}:{}:{}",
482 arg_name, file_path, arg_line, start_char
483 );
484
485 self.record_fixture_usage(
486 file_path,
487 arg_name.to_string(),
488 arg_line,
489 start_char,
490 end_char,
491 );
492 }
493 }
494
495 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
496 self.scan_function_body_for_undeclared_fixtures(
497 body,
498 file_path,
499 line_index,
500 &declared_params,
501 func_name,
502 function_line,
503 );
504 }
505
506 let is_test = func_name.starts_with("test_");
508
509 if is_test {
510 debug!("Found test function: {}", func_name);
511
512 let mut declared_params: HashSet<String> = HashSet::new();
513 declared_params.insert("self".to_string());
514 declared_params.insert("request".to_string());
515
516 for arg in Self::all_args(args) {
517 let arg_name = arg.def.arg.as_str();
518 declared_params.insert(arg_name.to_string());
519
520 if arg_name != "self" {
521 let arg_offset = arg.def.range.start().to_usize();
522 let arg_line = self.get_line_from_offset(arg_offset, line_index);
523 let start_char = self.get_char_position_from_offset(arg_offset, line_index);
524 let end_char = start_char + arg_name.len();
526
527 debug!(
528 "Parameter {} at offset {}, calculated line {}, char {}",
529 arg_name, arg_offset, arg_line, start_char
530 );
531 info!(
532 "Found fixture usage: {} at {:?}:{}:{}",
533 arg_name, file_path, arg_line, start_char
534 );
535
536 self.record_fixture_usage(
537 file_path,
538 arg_name.to_string(),
539 arg_line,
540 start_char,
541 end_char,
542 );
543 }
544 }
545
546 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
547 self.scan_function_body_for_undeclared_fixtures(
548 body,
549 file_path,
550 line_index,
551 &declared_params,
552 func_name,
553 function_line,
554 );
555 }
556 }
557
558 fn visit_assignment_fixture(
560 &self,
561 assign: &rustpython_parser::ast::StmtAssign,
562 file_path: &PathBuf,
563 _content: &str,
564 line_index: &[usize],
565 ) {
566 if let Expr::Call(outer_call) = &*assign.value {
567 if let Expr::Call(inner_call) = &*outer_call.func {
568 if decorators::is_fixture_decorator(&inner_call.func) {
569 for target in &assign.targets {
570 if let Expr::Name(name) = target {
571 let fixture_name = name.id.as_str();
572 let line = self
573 .get_line_from_offset(assign.range.start().to_usize(), line_index);
574
575 let start_char = self.get_char_position_from_offset(
576 name.range.start().to_usize(),
577 line_index,
578 );
579 let end_char = self.get_char_position_from_offset(
580 name.range.end().to_usize(),
581 line_index,
582 );
583
584 info!(
585 "Found fixture assignment: {} at {:?}:{}:{}-{}",
586 fixture_name, file_path, line, start_char, end_char
587 );
588
589 let is_third_party =
590 file_path.to_string_lossy().contains("site-packages");
591 let definition = FixtureDefinition {
592 name: fixture_name.to_string(),
593 file_path: file_path.clone(),
594 line,
595 end_line: line, start_char,
597 end_char,
598 docstring: None,
599 return_type: None,
600 is_third_party,
601 dependencies: Vec::new(), scope: FixtureScope::default(), yield_line: None, };
605
606 self.record_fixture_definition(definition);
607 }
608 }
609 }
610 }
611 }
612 }
613}
614
615impl FixtureDatabase {
617 fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
621 match stmt {
622 Stmt::Import(import_stmt) => {
623 for alias in &import_stmt.names {
624 let name = alias.asname.as_ref().unwrap_or(&alias.name);
625 names.insert(name.to_string());
626 }
627 }
628 Stmt::ImportFrom(import_from) => {
629 for alias in &import_from.names {
630 let name = alias.asname.as_ref().unwrap_or(&alias.name);
631 names.insert(name.to_string());
632 }
633 }
634 Stmt::FunctionDef(func_def) => {
635 let is_fixture = func_def
636 .decorator_list
637 .iter()
638 .any(decorators::is_fixture_decorator);
639 if !is_fixture {
640 names.insert(func_def.name.to_string());
641 }
642 }
643 Stmt::AsyncFunctionDef(func_def) => {
644 let is_fixture = func_def
645 .decorator_list
646 .iter()
647 .any(decorators::is_fixture_decorator);
648 if !is_fixture {
649 names.insert(func_def.name.to_string());
650 }
651 }
652 Stmt::ClassDef(class_def) => {
653 names.insert(class_def.name.to_string());
654 }
655 Stmt::Assign(assign) => {
656 for target in &assign.targets {
657 self.collect_names_from_expr(target, names);
658 }
659 }
660 Stmt::AnnAssign(ann_assign) => {
661 self.collect_names_from_expr(&ann_assign.target, names);
662 }
663 _ => {}
664 }
665 }
666
667 #[allow(clippy::only_used_in_recursion)]
668 pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
669 match expr {
670 Expr::Name(name) => {
671 names.insert(name.id.to_string());
672 }
673 Expr::Tuple(tuple) => {
674 for elt in &tuple.elts {
675 self.collect_names_from_expr(elt, names);
676 }
677 }
678 Expr::List(list) => {
679 for elt in &list.elts {
680 self.collect_names_from_expr(elt, names);
681 }
682 }
683 _ => {}
684 }
685 }
686
687 fn find_function_name_position(
691 &self,
692 content: &str,
693 line: usize,
694 func_name: &str,
695 ) -> (usize, usize) {
696 super::string_utils::find_function_name_position(content, line, func_name)
697 }
698
699 fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
702 for stmt in body {
703 if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
704 return Some(line);
705 }
706 }
707 None
708 }
709
710 fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
712 match stmt {
713 Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
714 Stmt::If(if_stmt) => {
715 for s in &if_stmt.body {
717 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
718 return Some(line);
719 }
720 }
721 for s in &if_stmt.orelse {
723 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
724 return Some(line);
725 }
726 }
727 None
728 }
729 Stmt::With(with_stmt) => {
730 for s in &with_stmt.body {
731 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
732 return Some(line);
733 }
734 }
735 None
736 }
737 Stmt::AsyncWith(with_stmt) => {
738 for s in &with_stmt.body {
739 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
740 return Some(line);
741 }
742 }
743 None
744 }
745 Stmt::Try(try_stmt) => {
746 for s in &try_stmt.body {
747 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
748 return Some(line);
749 }
750 }
751 for handler in &try_stmt.handlers {
752 let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
753 for s in &h.body {
754 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
755 return Some(line);
756 }
757 }
758 }
759 for s in &try_stmt.orelse {
760 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
761 return Some(line);
762 }
763 }
764 for s in &try_stmt.finalbody {
765 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
766 return Some(line);
767 }
768 }
769 None
770 }
771 Stmt::For(for_stmt) => {
772 for s in &for_stmt.body {
773 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
774 return Some(line);
775 }
776 }
777 for s in &for_stmt.orelse {
778 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
779 return Some(line);
780 }
781 }
782 None
783 }
784 Stmt::AsyncFor(for_stmt) => {
785 for s in &for_stmt.body {
786 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
787 return Some(line);
788 }
789 }
790 for s in &for_stmt.orelse {
791 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
792 return Some(line);
793 }
794 }
795 None
796 }
797 Stmt::While(while_stmt) => {
798 for s in &while_stmt.body {
799 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
800 return Some(line);
801 }
802 }
803 for s in &while_stmt.orelse {
804 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
805 return Some(line);
806 }
807 }
808 None
809 }
810 _ => None,
811 }
812 }
813
814 fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
816 match expr {
817 Expr::Yield(yield_expr) => {
818 let line =
819 self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
820 Some(line)
821 }
822 Expr::YieldFrom(yield_from) => {
823 let line =
824 self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
825 Some(line)
826 }
827 _ => None,
828 }
829 }
830}
831
832