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, error, 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 error!("Failed to parse Python file {:?}: {}", file_path, e);
46 return;
47 }
48 };
49
50 self.cleanup_usages_for_file(&file_path);
52 self.usages.remove(&file_path);
53
54 self.undeclared_fixtures.remove(&file_path);
56
57 self.imports.remove(&file_path);
59
60 if cleanup_previous {
67 self.cleanup_definitions_for_file(&file_path);
68 }
69
70 let is_conftest = file_path
72 .file_name()
73 .map(|n| n == "conftest.py")
74 .unwrap_or(false);
75 debug!("is_conftest: {}", is_conftest);
76
77 let line_index = self.get_line_index(&file_path, content);
79
80 if let rustpython_parser::ast::Mod::Module(module) = parsed {
82 debug!("Module has {} statements", module.body.len());
83
84 let mut module_level_names = HashSet::new();
86 for stmt in &module.body {
87 self.collect_module_level_names(stmt, &mut module_level_names);
88 }
89 self.imports.insert(file_path.clone(), module_level_names);
90
91 for stmt in &module.body {
93 self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
94 }
95 }
96
97 debug!("Analysis complete for {:?}", file_path);
98 }
99
100 fn cleanup_definitions_for_file(&self, file_path: &PathBuf) {
110 let fixture_names = match self.file_definitions.remove(file_path) {
112 Some((_, names)) => names,
113 None => return, };
115
116 for fixture_name in fixture_names {
118 let should_remove = {
119 if let Some(mut defs) = self.definitions.get_mut(&fixture_name) {
121 defs.retain(|def| def.file_path != *file_path);
122 defs.is_empty()
123 } else {
124 false
125 }
126 }; if should_remove {
130 self.definitions
133 .remove_if(&fixture_name, |_, defs| defs.is_empty());
134 }
135 }
136 }
137
138 fn cleanup_usages_for_file(&self, file_path: &PathBuf) {
144 let all_keys: Vec<String> = self
146 .usage_by_fixture
147 .iter()
148 .map(|entry| entry.key().clone())
149 .collect();
150
151 for fixture_name in all_keys {
153 let should_remove = {
154 if let Some(mut usages) = self.usage_by_fixture.get_mut(&fixture_name) {
155 let had_usages = usages.iter().any(|(path, _)| path == file_path);
156 if had_usages {
157 usages.retain(|(path, _)| path != file_path);
158 }
159 usages.is_empty()
160 } else {
161 false
162 }
163 };
164
165 if should_remove {
166 self.usage_by_fixture
167 .remove_if(&fixture_name, |_, usages| usages.is_empty());
168 }
169 }
170 }
171
172 pub(crate) fn build_line_index(content: &str) -> Vec<usize> {
175 let bytes = content.as_bytes();
176 let mut line_index = Vec::with_capacity(content.len() / 30);
177 line_index.push(0);
178 for i in memchr::memchr_iter(b'\n', bytes) {
179 line_index.push(i + 1);
180 }
181 line_index
182 }
183
184 pub(crate) fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
186 match line_index.binary_search(&offset) {
187 Ok(line) => line + 1,
188 Err(line) => line,
189 }
190 }
191
192 pub(crate) fn get_char_position_from_offset(
194 &self,
195 offset: usize,
196 line_index: &[usize],
197 ) -> usize {
198 let line = self.get_line_from_offset(offset, line_index);
199 let line_start = line_index[line - 1];
200 offset.saturating_sub(line_start)
201 }
202
203 pub(crate) fn all_args(args: &Arguments) -> impl Iterator<Item = &ArgWithDefault> {
207 args.posonlyargs
208 .iter()
209 .chain(args.args.iter())
210 .chain(args.kwonlyargs.iter())
211 }
212
213 fn record_fixture_usage(
217 &self,
218 file_path: &Path,
219 fixture_name: String,
220 line: usize,
221 start_char: usize,
222 end_char: usize,
223 ) {
224 let file_path_buf = file_path.to_path_buf();
225 let usage = FixtureUsage {
226 name: fixture_name.clone(),
227 file_path: file_path_buf.clone(),
228 line,
229 start_char,
230 end_char,
231 };
232
233 self.usages
235 .entry(file_path_buf.clone())
236 .or_default()
237 .push(usage.clone());
238
239 self.usage_by_fixture
241 .entry(fixture_name)
242 .or_default()
243 .push((file_path_buf, usage));
244 }
245
246 fn record_fixture_definition(&self, definition: FixtureDefinition) {
249 let file_path = definition.file_path.clone();
250 let fixture_name = definition.name.clone();
251
252 self.definitions
254 .entry(fixture_name.clone())
255 .or_default()
256 .push(definition);
257
258 self.file_definitions
260 .entry(file_path)
261 .or_default()
262 .insert(fixture_name);
263
264 self.invalidate_cycle_cache();
266 }
267
268 fn visit_stmt(
270 &self,
271 stmt: &Stmt,
272 file_path: &PathBuf,
273 _is_conftest: bool,
274 content: &str,
275 line_index: &[usize],
276 ) {
277 if let Stmt::Assign(assign) = stmt {
279 self.visit_assignment_fixture(assign, file_path, content, line_index);
280 }
281
282 if let Stmt::ClassDef(class_def) = stmt {
284 for decorator in &class_def.decorator_list {
286 let usefixtures = decorators::extract_usefixtures_names(decorator);
287 for (fixture_name, range) in usefixtures {
288 let usage_line =
289 self.get_line_from_offset(range.start().to_usize(), line_index);
290 let start_char =
291 self.get_char_position_from_offset(range.start().to_usize(), line_index);
292 let end_char =
293 self.get_char_position_from_offset(range.end().to_usize(), line_index);
294
295 info!(
296 "Found usefixtures usage on class: {} at {:?}:{}:{}",
297 fixture_name, file_path, usage_line, start_char
298 );
299
300 self.record_fixture_usage(
301 file_path,
302 fixture_name,
303 usage_line,
304 start_char + 1,
305 end_char - 1,
306 );
307 }
308 }
309
310 for class_stmt in &class_def.body {
311 self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
312 }
313 return;
314 }
315
316 let (func_name, decorator_list, args, range, body, returns) = match stmt {
318 Stmt::FunctionDef(func_def) => (
319 func_def.name.as_str(),
320 &func_def.decorator_list,
321 &func_def.args,
322 func_def.range,
323 &func_def.body,
324 &func_def.returns,
325 ),
326 Stmt::AsyncFunctionDef(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 _ => return,
335 };
336
337 debug!("Found function: {}", func_name);
338
339 for decorator in decorator_list {
341 let usefixtures = decorators::extract_usefixtures_names(decorator);
342 for (fixture_name, range) in usefixtures {
343 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
344 let start_char =
345 self.get_char_position_from_offset(range.start().to_usize(), line_index);
346 let end_char =
347 self.get_char_position_from_offset(range.end().to_usize(), line_index);
348
349 info!(
350 "Found usefixtures usage on function: {} at {:?}:{}:{}",
351 fixture_name, file_path, usage_line, start_char
352 );
353
354 self.record_fixture_usage(
355 file_path,
356 fixture_name,
357 usage_line,
358 start_char + 1,
359 end_char - 1,
360 );
361 }
362 }
363
364 for decorator in decorator_list {
366 let indirect_fixtures = decorators::extract_parametrize_indirect_fixtures(decorator);
367 for (fixture_name, range) in indirect_fixtures {
368 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
369 let start_char =
370 self.get_char_position_from_offset(range.start().to_usize(), line_index);
371 let end_char =
372 self.get_char_position_from_offset(range.end().to_usize(), line_index);
373
374 info!(
375 "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
376 fixture_name, file_path, usage_line, start_char
377 );
378
379 self.record_fixture_usage(
380 file_path,
381 fixture_name,
382 usage_line,
383 start_char + 1,
384 end_char - 1,
385 );
386 }
387 }
388
389 debug!(
391 "Function {} has {} decorators",
392 func_name,
393 decorator_list.len()
394 );
395 let fixture_decorator = decorator_list
396 .iter()
397 .find(|dec| decorators::is_fixture_decorator(dec));
398
399 if let Some(decorator) = fixture_decorator {
400 debug!(" Decorator matched as fixture!");
401
402 let fixture_name = decorators::extract_fixture_name_from_decorator(decorator)
404 .unwrap_or_else(|| func_name.to_string());
405
406 let scope = decorators::extract_fixture_scope(decorator).unwrap_or_default();
408
409 let line = self.get_line_from_offset(range.start().to_usize(), line_index);
410 let docstring = self.extract_docstring(body);
411 let return_type = self.extract_return_type(returns, body, content);
412
413 info!(
414 "Found fixture definition: {} (function: {}, scope: {:?}) at {:?}:{}",
415 fixture_name, func_name, scope, file_path, line
416 );
417
418 let (start_char, end_char) = self.find_function_name_position(content, line, func_name);
419
420 let is_third_party = file_path.to_string_lossy().contains("site-packages");
421
422 let mut declared_params: HashSet<String> = HashSet::new();
424 let mut dependencies: Vec<String> = Vec::new();
425 declared_params.insert("self".to_string());
426 declared_params.insert("request".to_string());
427 declared_params.insert(func_name.to_string());
428
429 for arg in Self::all_args(args) {
430 let arg_name = arg.def.arg.as_str();
431 declared_params.insert(arg_name.to_string());
432 if arg_name != "self" && arg_name != "request" {
434 dependencies.push(arg_name.to_string());
435 }
436 }
437
438 let definition = FixtureDefinition {
439 name: fixture_name.clone(),
440 file_path: file_path.clone(),
441 line,
442 start_char,
443 end_char,
444 docstring,
445 return_type,
446 is_third_party,
447 dependencies: dependencies.clone(),
448 scope,
449 yield_line: self.find_yield_line(body, line_index),
450 };
451
452 self.record_fixture_definition(definition);
453
454 for arg in Self::all_args(args) {
456 let arg_name = arg.def.arg.as_str();
457
458 if arg_name != "self" && arg_name != "request" {
459 let arg_line =
460 self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
461 let start_char = self.get_char_position_from_offset(
462 arg.def.range.start().to_usize(),
463 line_index,
464 );
465 let end_char = self
466 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
467
468 info!(
469 "Found fixture dependency: {} at {:?}:{}:{}",
470 arg_name, file_path, arg_line, start_char
471 );
472
473 self.record_fixture_usage(
474 file_path,
475 arg_name.to_string(),
476 arg_line,
477 start_char,
478 end_char,
479 );
480 }
481 }
482
483 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
484 self.scan_function_body_for_undeclared_fixtures(
485 body,
486 file_path,
487 line_index,
488 &declared_params,
489 func_name,
490 function_line,
491 );
492 }
493
494 let is_test = func_name.starts_with("test_");
496
497 if is_test {
498 debug!("Found test function: {}", func_name);
499
500 let mut declared_params: HashSet<String> = HashSet::new();
501 declared_params.insert("self".to_string());
502 declared_params.insert("request".to_string());
503
504 for arg in Self::all_args(args) {
505 let arg_name = arg.def.arg.as_str();
506 declared_params.insert(arg_name.to_string());
507
508 if arg_name != "self" {
509 let arg_offset = arg.def.range.start().to_usize();
510 let arg_line = self.get_line_from_offset(arg_offset, line_index);
511 let start_char = self.get_char_position_from_offset(arg_offset, line_index);
512 let end_char = self
513 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
514
515 debug!(
516 "Parameter {} at offset {}, calculated line {}, char {}",
517 arg_name, arg_offset, arg_line, start_char
518 );
519 info!(
520 "Found fixture usage: {} at {:?}:{}:{}",
521 arg_name, file_path, arg_line, start_char
522 );
523
524 self.record_fixture_usage(
525 file_path,
526 arg_name.to_string(),
527 arg_line,
528 start_char,
529 end_char,
530 );
531 }
532 }
533
534 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
535 self.scan_function_body_for_undeclared_fixtures(
536 body,
537 file_path,
538 line_index,
539 &declared_params,
540 func_name,
541 function_line,
542 );
543 }
544 }
545
546 fn visit_assignment_fixture(
548 &self,
549 assign: &rustpython_parser::ast::StmtAssign,
550 file_path: &PathBuf,
551 _content: &str,
552 line_index: &[usize],
553 ) {
554 if let Expr::Call(outer_call) = &*assign.value {
555 if let Expr::Call(inner_call) = &*outer_call.func {
556 if decorators::is_fixture_decorator(&inner_call.func) {
557 for target in &assign.targets {
558 if let Expr::Name(name) = target {
559 let fixture_name = name.id.as_str();
560 let line = self
561 .get_line_from_offset(assign.range.start().to_usize(), line_index);
562
563 let start_char = self.get_char_position_from_offset(
564 name.range.start().to_usize(),
565 line_index,
566 );
567 let end_char = self.get_char_position_from_offset(
568 name.range.end().to_usize(),
569 line_index,
570 );
571
572 info!(
573 "Found fixture assignment: {} at {:?}:{}:{}-{}",
574 fixture_name, file_path, line, start_char, end_char
575 );
576
577 let is_third_party =
578 file_path.to_string_lossy().contains("site-packages");
579 let definition = FixtureDefinition {
580 name: fixture_name.to_string(),
581 file_path: file_path.clone(),
582 line,
583 start_char,
584 end_char,
585 docstring: None,
586 return_type: None,
587 is_third_party,
588 dependencies: Vec::new(), scope: FixtureScope::default(), yield_line: None, };
592
593 self.record_fixture_definition(definition);
594 }
595 }
596 }
597 }
598 }
599 }
600}
601
602impl FixtureDatabase {
604 fn collect_module_level_names(&self, stmt: &Stmt, names: &mut HashSet<String>) {
608 match stmt {
609 Stmt::Import(import_stmt) => {
610 for alias in &import_stmt.names {
611 let name = alias.asname.as_ref().unwrap_or(&alias.name);
612 names.insert(name.to_string());
613 }
614 }
615 Stmt::ImportFrom(import_from) => {
616 for alias in &import_from.names {
617 let name = alias.asname.as_ref().unwrap_or(&alias.name);
618 names.insert(name.to_string());
619 }
620 }
621 Stmt::FunctionDef(func_def) => {
622 let is_fixture = func_def
623 .decorator_list
624 .iter()
625 .any(decorators::is_fixture_decorator);
626 if !is_fixture {
627 names.insert(func_def.name.to_string());
628 }
629 }
630 Stmt::AsyncFunctionDef(func_def) => {
631 let is_fixture = func_def
632 .decorator_list
633 .iter()
634 .any(decorators::is_fixture_decorator);
635 if !is_fixture {
636 names.insert(func_def.name.to_string());
637 }
638 }
639 Stmt::ClassDef(class_def) => {
640 names.insert(class_def.name.to_string());
641 }
642 Stmt::Assign(assign) => {
643 for target in &assign.targets {
644 self.collect_names_from_expr(target, names);
645 }
646 }
647 Stmt::AnnAssign(ann_assign) => {
648 self.collect_names_from_expr(&ann_assign.target, names);
649 }
650 _ => {}
651 }
652 }
653
654 #[allow(clippy::only_used_in_recursion)]
655 pub(crate) fn collect_names_from_expr(&self, expr: &Expr, names: &mut HashSet<String>) {
656 match expr {
657 Expr::Name(name) => {
658 names.insert(name.id.to_string());
659 }
660 Expr::Tuple(tuple) => {
661 for elt in &tuple.elts {
662 self.collect_names_from_expr(elt, names);
663 }
664 }
665 Expr::List(list) => {
666 for elt in &list.elts {
667 self.collect_names_from_expr(elt, names);
668 }
669 }
670 _ => {}
671 }
672 }
673
674 fn find_function_name_position(
678 &self,
679 content: &str,
680 line: usize,
681 func_name: &str,
682 ) -> (usize, usize) {
683 super::string_utils::find_function_name_position(content, line, func_name)
684 }
685
686 fn find_yield_line(&self, body: &[Stmt], line_index: &[usize]) -> Option<usize> {
689 for stmt in body {
690 if let Some(line) = self.find_yield_in_stmt(stmt, line_index) {
691 return Some(line);
692 }
693 }
694 None
695 }
696
697 fn find_yield_in_stmt(&self, stmt: &Stmt, line_index: &[usize]) -> Option<usize> {
699 match stmt {
700 Stmt::Expr(expr_stmt) => self.find_yield_in_expr(&expr_stmt.value, line_index),
701 Stmt::If(if_stmt) => {
702 for s in &if_stmt.body {
704 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
705 return Some(line);
706 }
707 }
708 for s in &if_stmt.orelse {
710 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
711 return Some(line);
712 }
713 }
714 None
715 }
716 Stmt::With(with_stmt) => {
717 for s in &with_stmt.body {
718 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
719 return Some(line);
720 }
721 }
722 None
723 }
724 Stmt::AsyncWith(with_stmt) => {
725 for s in &with_stmt.body {
726 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
727 return Some(line);
728 }
729 }
730 None
731 }
732 Stmt::Try(try_stmt) => {
733 for s in &try_stmt.body {
734 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
735 return Some(line);
736 }
737 }
738 for handler in &try_stmt.handlers {
739 let rustpython_parser::ast::ExceptHandler::ExceptHandler(h) = handler;
740 for s in &h.body {
741 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
742 return Some(line);
743 }
744 }
745 }
746 for s in &try_stmt.orelse {
747 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
748 return Some(line);
749 }
750 }
751 for s in &try_stmt.finalbody {
752 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
753 return Some(line);
754 }
755 }
756 None
757 }
758 Stmt::For(for_stmt) => {
759 for s in &for_stmt.body {
760 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
761 return Some(line);
762 }
763 }
764 for s in &for_stmt.orelse {
765 if let Some(line) = self.find_yield_in_stmt(s, line_index) {
766 return Some(line);
767 }
768 }
769 None
770 }
771 Stmt::AsyncFor(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::While(while_stmt) => {
785 for s in &while_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 &while_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 _ => None,
798 }
799 }
800
801 fn find_yield_in_expr(&self, expr: &Expr, line_index: &[usize]) -> Option<usize> {
803 match expr {
804 Expr::Yield(yield_expr) => {
805 let line =
806 self.get_line_from_offset(yield_expr.range.start().to_usize(), line_index);
807 Some(line)
808 }
809 Expr::YieldFrom(yield_from) => {
810 let line =
811 self.get_line_from_offset(yield_from.range.start().to_usize(), line_index);
812 Some(line)
813 }
814 _ => None,
815 }
816 }
817}
818
819