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