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 let autouse = decorators::extract_fixture_autouse(decorator);
417
418 let line = self.get_line_from_offset(range.start().to_usize(), line_index);
419 let docstring = self.extract_docstring(body);
420 let return_type = self.extract_return_type(returns, body, content);
421
422 info!(
423 "Found fixture definition: {} (function: {}, scope: {:?}) at {:?}:{}",
424 fixture_name, func_name, scope, file_path, line
425 );
426
427 let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
428
429 let is_third_party = file_path.to_string_lossy().contains("site-packages")
430 || self.is_editable_install_third_party(file_path);
431
432 let mut declared_params: HashSet<String> = HashSet::new();
434 let mut dependencies: Vec<String> = Vec::new();
435 declared_params.insert("self".to_string());
436 declared_params.insert("request".to_string());
437 declared_params.insert(func_name.to_string());
438
439 for arg in Self::all_args(args) {
440 let arg_name = arg.def.arg.as_str();
441 declared_params.insert(arg_name.to_string());
442 if arg_name != "self" && arg_name != "request" {
444 dependencies.push(arg_name.to_string());
445 }
446 }
447
448 let end_line = self.get_line_from_offset(range.end().to_usize(), line_index);
450
451 let definition = FixtureDefinition {
452 name: fixture_name.clone(),
453 file_path: file_path.clone(),
454 line,
455 end_line,
456 start_char,
457 end_char,
458 docstring,
459 return_type,
460 is_third_party,
461 dependencies: dependencies.clone(),
462 scope,
463 yield_line: self.find_yield_line(body, line_index),
464 autouse,
465 };
466
467 self.record_fixture_definition(definition);
468
469 for arg in Self::all_args(args) {
471 let arg_name = arg.def.arg.as_str();
472
473 if arg_name != "self" && arg_name != "request" {
474 let arg_line =
475 self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
476 let start_char = self.get_char_position_from_offset(
477 arg.def.range.start().to_usize(),
478 line_index,
479 );
480 let end_char = start_char + arg_name.len();
482
483 info!(
484 "Found fixture dependency: {} at {:?}:{}:{}",
485 arg_name, file_path, arg_line, start_char
486 );
487
488 self.record_fixture_usage(
489 file_path,
490 arg_name.to_string(),
491 arg_line,
492 start_char,
493 end_char,
494 );
495 }
496 }
497
498 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
499 self.scan_function_body_for_undeclared_fixtures(
500 body,
501 file_path,
502 line_index,
503 &declared_params,
504 func_name,
505 function_line,
506 );
507 }
508
509 let is_test = func_name.starts_with("test_");
511
512 if is_test {
513 debug!("Found test function: {}", func_name);
514
515 let mut declared_params: HashSet<String> = HashSet::new();
516 declared_params.insert("self".to_string());
517 declared_params.insert("request".to_string());
518
519 for arg in Self::all_args(args) {
520 let arg_name = arg.def.arg.as_str();
521 declared_params.insert(arg_name.to_string());
522
523 if arg_name != "self" {
524 let arg_offset = arg.def.range.start().to_usize();
525 let arg_line = self.get_line_from_offset(arg_offset, line_index);
526 let start_char = self.get_char_position_from_offset(arg_offset, line_index);
527 let end_char = start_char + arg_name.len();
529
530 debug!(
531 "Parameter {} at offset {}, calculated line {}, char {}",
532 arg_name, arg_offset, arg_line, start_char
533 );
534 info!(
535 "Found fixture usage: {} at {:?}:{}:{}",
536 arg_name, file_path, arg_line, start_char
537 );
538
539 self.record_fixture_usage(
540 file_path,
541 arg_name.to_string(),
542 arg_line,
543 start_char,
544 end_char,
545 );
546 }
547 }
548
549 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
550 self.scan_function_body_for_undeclared_fixtures(
551 body,
552 file_path,
553 line_index,
554 &declared_params,
555 func_name,
556 function_line,
557 );
558 }
559 }
560
561 fn visit_assignment_fixture(
563 &self,
564 assign: &rustpython_parser::ast::StmtAssign,
565 file_path: &PathBuf,
566 _content: &str,
567 line_index: &[usize],
568 ) {
569 if let Expr::Call(outer_call) = &*assign.value {
570 if let Expr::Call(inner_call) = &*outer_call.func {
571 if decorators::is_fixture_decorator(&inner_call.func) {
572 for target in &assign.targets {
573 if let Expr::Name(name) = target {
574 let fixture_name = name.id.as_str();
575 let line = self
576 .get_line_from_offset(assign.range.start().to_usize(), line_index);
577
578 let start_char = self.get_char_position_from_offset(
579 name.range.start().to_usize(),
580 line_index,
581 );
582 let end_char = self.get_char_position_from_offset(
583 name.range.end().to_usize(),
584 line_index,
585 );
586
587 info!(
588 "Found fixture assignment: {} at {:?}:{}:{}-{}",
589 fixture_name, file_path, line, start_char, end_char
590 );
591
592 let is_third_party =
593 file_path.to_string_lossy().contains("site-packages");
594 let definition = FixtureDefinition {
595 name: fixture_name.to_string(),
596 file_path: file_path.clone(),
597 line,
598 end_line: line, start_char,
600 end_char,
601 docstring: None,
602 return_type: None,
603 is_third_party,
604 dependencies: Vec::new(), scope: FixtureScope::default(), yield_line: None, autouse: false, };
609
610 self.record_fixture_definition(definition);
611 }
612 }
613 }
614 }
615 }
616 }
617}
618
619impl FixtureDatabase {
621 fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
625 match stmt {
626 Stmt::Import(import_stmt) => {
627 for alias in &import_stmt.names {
628 let name = alias.asname.as_ref().unwrap_or(&alias.name);
629 names.insert(name.to_string());
630 }
631 }
632 Stmt::ImportFrom(import_from) => {
633 for alias in &import_from.names {
634 let name = alias.asname.as_ref().unwrap_or(&alias.name);
635 names.insert(name.to_string());
636 }
637 }
638 Stmt::FunctionDef(func_def) => {
639 let is_fixture = func_def
640 .decorator_list
641 .iter()
642 .any(decorators::is_fixture_decorator);
643 if !is_fixture {
644 names.insert(func_def.name.to_string());
645 }
646 }
647 Stmt::AsyncFunctionDef(func_def) => {
648 let is_fixture = func_def
649 .decorator_list
650 .iter()
651 .any(decorators::is_fixture_decorator);
652 if !is_fixture {
653 names.insert(func_def.name.to_string());
654 }
655 }
656 Stmt::ClassDef(class_def) => {
657 names.insert(class_def.name.to_string());
658 }
659 Stmt::Assign(assign) => {
660 for target in &assign.targets {
661 self.collect_names_from_expr(target, names);
662 }
663 }
664 Stmt::AnnAssign(ann_assign) => {
665 self.collect_names_from_expr(&ann_assign.target, names);
666 }
667 _ => {}
668 }
669 }
670
671 #[allow(clippy::only_used_in_recursion)]
672 pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
673 match expr {
674 Expr::Name(name) => {
675 names.insert(name.id.to_string());
676 }
677 Expr::Tuple(tuple) => {
678 for elt in &tuple.elts {
679 self.collect_names_from_expr(elt, names);
680 }
681 }
682 Expr::List(list) => {
683 for elt in &list.elts {
684 self.collect_names_from_expr(elt, names);
685 }
686 }
687 _ => {}
688 }
689 }
690
691 fn find_function_name_position(
695 &self,
696 content: &str,
697 line: usize,
698 func_name: &str,
699 ) -> (usize, usize) {
700 super::string_utils::find_function_name_position(content, line, func_name)
701 }
702
703 fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
706 for stmt in body {
707 if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
708 return Some(line);
709 }
710 }
711 None
712 }
713
714 fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
716 match stmt {
717 Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
718 Stmt::If(if_stmt) => {
719 for s in &if_stmt.body {
721 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
722 return Some(line);
723 }
724 }
725 for s in &if_stmt.orelse {
727 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
728 return Some(line);
729 }
730 }
731 None
732 }
733 Stmt::With(with_stmt) => {
734 for s in &with_stmt.body {
735 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
736 return Some(line);
737 }
738 }
739 None
740 }
741 Stmt::AsyncWith(with_stmt) => {
742 for s in &with_stmt.body {
743 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
744 return Some(line);
745 }
746 }
747 None
748 }
749 Stmt::Try(try_stmt) => {
750 for s in &try_stmt.body {
751 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
752 return Some(line);
753 }
754 }
755 for handler in &try_stmt.handlers {
756 let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
757 for s in &h.body {
758 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
759 return Some(line);
760 }
761 }
762 }
763 for s in &try_stmt.orelse {
764 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
765 return Some(line);
766 }
767 }
768 for s in &try_stmt.finalbody {
769 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
770 return Some(line);
771 }
772 }
773 None
774 }
775 Stmt::For(for_stmt) => {
776 for s in &for_stmt.body {
777 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
778 return Some(line);
779 }
780 }
781 for s in &for_stmt.orelse {
782 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
783 return Some(line);
784 }
785 }
786 None
787 }
788 Stmt::AsyncFor(for_stmt) => {
789 for s in &for_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 &for_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::While(while_stmt) => {
802 for s in &while_stmt.body {
803 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
804 return Some(line);
805 }
806 }
807 for s in &while_stmt.orelse {
808 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
809 return Some(line);
810 }
811 }
812 None
813 }
814 _ => None,
815 }
816 }
817
818 fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
820 match expr {
821 Expr::Yield(yield_expr) => {
822 let line =
823 self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
824 Some(line)
825 }
826 Expr::YieldFrom(yield_from) => {
827 let line =
828 self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
829 Some(line)
830 }
831 _ => None,
832 }
833 }
834}
835
836