1use dashmap::DashMap;
2use rustpython_parser::ast::{Expr, Stmt};
3use rustpython_parser::{parse, Mode};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use tracing::{debug, info, warn};
7use walkdir::WalkDir;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct FixtureDefinition {
11 pub name: String,
12 pub file_path: PathBuf,
13 pub line: usize,
14 pub docstring: Option<String>,
15}
16
17#[derive(Debug, Clone)]
18pub struct FixtureUsage {
19 pub name: String,
20 pub file_path: PathBuf,
21 pub line: usize,
22 pub start_char: usize, pub end_char: usize, }
25
26#[derive(Debug, Clone)]
27pub struct UndeclaredFixture {
28 pub name: String,
29 pub file_path: PathBuf,
30 pub line: usize,
31 pub start_char: usize,
32 pub end_char: usize,
33 pub function_name: String, pub function_line: usize, }
36
37#[derive(Debug)]
38pub struct FixtureDatabase {
39 definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
41 usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
43 file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
45 undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
47 imports: Arc<DashMap<PathBuf, std::collections::HashSet<String>>>,
49}
50
51impl Default for FixtureDatabase {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl FixtureDatabase {
58 pub fn new() -> Self {
59 Self {
60 definitions: Arc::new(DashMap::new()),
61 usages: Arc::new(DashMap::new()),
62 file_cache: Arc::new(DashMap::new()),
63 undeclared_fixtures: Arc::new(DashMap::new()),
64 imports: Arc::new(DashMap::new()),
65 }
66 }
67
68 pub fn scan_workspace(&self, root_path: &Path) {
70 info!("Scanning workspace: {:?}", root_path);
71 let mut file_count = 0;
72
73 for entry in WalkDir::new(root_path).into_iter().filter_map(|e| e.ok()) {
74 let path = entry.path();
75
76 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
78 if filename == "conftest.py"
79 || filename.starts_with("test_") && filename.ends_with(".py")
80 || filename.ends_with("_test.py")
81 {
82 debug!("Found test/conftest file: {:?}", path);
83 if let Ok(content) = std::fs::read_to_string(path) {
84 self.analyze_file(path.to_path_buf(), &content);
85 file_count += 1;
86 }
87 }
88 }
89 }
90
91 info!("Workspace scan complete. Processed {} files", file_count);
92
93 self.scan_venv_fixtures(root_path);
95
96 info!("Total fixtures defined: {}", self.definitions.len());
97 info!("Total files with fixture usages: {}", self.usages.len());
98 }
99
100 fn scan_venv_fixtures(&self, root_path: &Path) {
102 info!("Scanning for pytest plugins in virtual environment");
103
104 let venv_paths = vec![
106 root_path.join(".venv"),
107 root_path.join("venv"),
108 root_path.join("env"),
109 ];
110
111 info!("Checking for venv in: {:?}", root_path);
112 for venv_path in &venv_paths {
113 debug!("Checking venv path: {:?}", venv_path);
114 if venv_path.exists() {
115 info!("Found virtual environment at: {:?}", venv_path);
116 self.scan_venv_site_packages(venv_path);
117 return;
118 } else {
119 debug!(" Does not exist: {:?}", venv_path);
120 }
121 }
122
123 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
125 info!("Found VIRTUAL_ENV environment variable: {}", venv);
126 let venv_path = PathBuf::from(venv);
127 if venv_path.exists() {
128 info!("Using VIRTUAL_ENV: {:?}", venv_path);
129 self.scan_venv_site_packages(&venv_path);
130 return;
131 } else {
132 warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
133 }
134 } else {
135 debug!("No VIRTUAL_ENV environment variable set");
136 }
137
138 warn!("No virtual environment found - third-party fixtures will not be available");
139 }
140
141 fn scan_venv_site_packages(&self, venv_path: &Path) {
142 info!("Scanning venv site-packages in: {:?}", venv_path);
143
144 let lib_path = venv_path.join("lib");
146 debug!("Checking lib path: {:?}", lib_path);
147
148 if lib_path.exists() {
149 if let Ok(entries) = std::fs::read_dir(&lib_path) {
151 for entry in entries.flatten() {
152 let path = entry.path();
153 let dirname = path.file_name().unwrap_or_default().to_string_lossy();
154 debug!("Found in lib: {:?}", dirname);
155
156 if path.is_dir() && dirname.starts_with("python") {
157 let site_packages = path.join("site-packages");
158 debug!("Checking site-packages: {:?}", site_packages);
159
160 if site_packages.exists() {
161 info!("Found site-packages: {:?}", site_packages);
162 self.scan_pytest_plugins(&site_packages);
163 return;
164 }
165 }
166 }
167 }
168 }
169
170 let windows_site_packages = venv_path.join("Lib/site-packages");
172 debug!("Checking Windows path: {:?}", windows_site_packages);
173 if windows_site_packages.exists() {
174 info!("Found site-packages (Windows): {:?}", windows_site_packages);
175 self.scan_pytest_plugins(&windows_site_packages);
176 return;
177 }
178
179 warn!("Could not find site-packages in venv: {:?}", venv_path);
180 }
181
182 fn scan_pytest_plugins(&self, site_packages: &Path) {
183 info!("Scanning pytest plugins in: {:?}", site_packages);
184
185 let pytest_packages = vec![
187 "pytest_mock",
188 "pytest-mock",
189 "pytest_asyncio",
190 "pytest-asyncio",
191 "pytest_django",
192 "pytest-django",
193 "pytest_cov",
194 "pytest-cov",
195 "pytest_xdist",
196 "pytest-xdist",
197 "pytest_fixtures",
198 ];
199
200 let mut plugin_count = 0;
201
202 for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
203 let entry = match entry {
204 Ok(e) => e,
205 Err(_) => continue,
206 };
207
208 let path = entry.path();
209 let filename = path.file_name().unwrap_or_default().to_string_lossy();
210
211 let is_pytest_package = pytest_packages.iter().any(|pkg| filename.contains(pkg))
213 || filename.starts_with("pytest")
214 || filename.contains("_pytest");
215
216 if is_pytest_package && path.is_dir() {
217 if filename.ends_with(".dist-info") || filename.ends_with(".egg-info") {
219 debug!("Skipping dist-info directory: {:?}", filename);
220 continue;
221 }
222
223 info!("Scanning pytest plugin: {:?}", path);
224 plugin_count += 1;
225 self.scan_plugin_directory(&path);
226 } else {
227 if filename.contains("mock") {
229 debug!("Found mock-related package (not scanning): {:?}", filename);
230 }
231 }
232 }
233
234 info!("Scanned {} pytest plugin packages", plugin_count);
235 }
236
237 fn scan_plugin_directory(&self, plugin_dir: &Path) {
238 for entry in WalkDir::new(plugin_dir)
240 .max_depth(3) .into_iter()
242 .filter_map(|e| e.ok())
243 {
244 let path = entry.path();
245
246 if path.extension().and_then(|s| s.to_str()) == Some("py") {
247 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
249 if filename.starts_with("test_") || filename.contains("__pycache__") {
251 continue;
252 }
253
254 debug!("Scanning plugin file: {:?}", path);
255 if let Ok(content) = std::fs::read_to_string(path) {
256 self.analyze_file(path.to_path_buf(), &content);
257 }
258 }
259 }
260 }
261 }
262
263 pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
265 let file_path = file_path.canonicalize().unwrap_or_else(|_| {
268 debug!(
271 "Warning: Could not canonicalize path {:?}, using as-is",
272 file_path
273 );
274 file_path
275 });
276
277 debug!("Analyzing file: {:?}", file_path);
278
279 self.file_cache
282 .insert(file_path.clone(), Arc::new(content.to_string()));
283
284 let parsed = match parse(content, Mode::Module, "") {
286 Ok(ast) => ast,
287 Err(e) => {
288 warn!("Failed to parse {:?}: {:?}", file_path, e);
289 return;
290 }
291 };
292
293 self.usages.remove(&file_path);
295
296 self.undeclared_fixtures.remove(&file_path);
298
299 self.imports.remove(&file_path);
301
302 for mut entry in self.definitions.iter_mut() {
305 entry.value_mut().retain(|def| def.file_path != file_path);
306 }
307 self.definitions.retain(|_, defs| !defs.is_empty());
309
310 let is_conftest = file_path
312 .file_name()
313 .map(|n| n == "conftest.py")
314 .unwrap_or(false);
315 debug!("is_conftest: {}", is_conftest);
316
317 if let rustpython_parser::ast::Mod::Module(module) = parsed {
319 debug!("Module has {} statements", module.body.len());
320
321 let mut module_level_names = std::collections::HashSet::new();
323 for stmt in &module.body {
324 self.collect_module_level_names(stmt, &mut module_level_names);
325 }
326 self.imports.insert(file_path.clone(), module_level_names);
327
328 for stmt in &module.body {
330 self.visit_stmt(stmt, &file_path, is_conftest, content);
331 }
332 }
333
334 debug!("Analysis complete for {:?}", file_path);
335 }
336
337 fn visit_stmt(&self, stmt: &Stmt, file_path: &PathBuf, _is_conftest: bool, content: &str) {
338 if let Stmt::Assign(assign) = stmt {
340 self.visit_assignment_fixture(assign, file_path, content);
341 }
342
343 let (func_name, decorator_list, args, range, body) = match stmt {
345 Stmt::FunctionDef(func_def) => (
346 func_def.name.as_str(),
347 &func_def.decorator_list,
348 &func_def.args,
349 func_def.range,
350 &func_def.body,
351 ),
352 Stmt::AsyncFunctionDef(func_def) => (
353 func_def.name.as_str(),
354 &func_def.decorator_list,
355 &func_def.args,
356 func_def.range,
357 &func_def.body,
358 ),
359 _ => return,
360 };
361
362 debug!("Found function: {}", func_name);
363
364 debug!(
366 "Function {} has {} decorators",
367 func_name,
368 decorator_list.len()
369 );
370 let is_fixture = decorator_list.iter().any(|dec| {
371 let result = Self::is_fixture_decorator(dec);
372 if result {
373 debug!(" Decorator matched as fixture!");
374 }
375 result
376 });
377
378 if is_fixture {
379 let line = self.get_line_from_offset(range.start().to_usize(), content);
381
382 let docstring = self.extract_docstring(body);
384
385 info!(
386 "Found fixture definition: {} at {:?}:{}",
387 func_name, file_path, line
388 );
389 if let Some(ref doc) = docstring {
390 debug!(" Docstring: {}", doc);
391 }
392
393 let definition = FixtureDefinition {
394 name: func_name.to_string(),
395 file_path: file_path.clone(),
396 line,
397 docstring,
398 };
399
400 self.definitions
401 .entry(func_name.to_string())
402 .or_default()
403 .push(definition);
404
405 let mut declared_params: std::collections::HashSet<String> =
407 std::collections::HashSet::new();
408 declared_params.insert("self".to_string());
409 declared_params.insert("request".to_string());
410 declared_params.insert(func_name.to_string()); for arg in &args.args {
413 let arg_name = arg.def.arg.as_str();
414 declared_params.insert(arg_name.to_string());
415
416 if arg_name != "self" && arg_name != "request" {
417 let arg_line =
420 self.get_line_from_offset(arg.def.range.start().to_usize(), content);
421 let start_char = self
422 .get_char_position_from_offset(arg.def.range.start().to_usize(), content);
423 let end_char =
424 self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
425
426 info!(
427 "Found fixture dependency: {} at {:?}:{}:{}",
428 arg_name, file_path, arg_line, start_char
429 );
430
431 let usage = FixtureUsage {
432 name: arg_name.to_string(),
433 file_path: file_path.clone(),
434 line: arg_line, start_char,
436 end_char,
437 };
438
439 self.usages
440 .entry(file_path.clone())
441 .or_default()
442 .push(usage);
443 }
444 }
445
446 let function_line = self.get_line_from_offset(range.start().to_usize(), content);
448 self.scan_function_body_for_undeclared_fixtures(
449 body,
450 file_path,
451 content,
452 &declared_params,
453 func_name,
454 function_line,
455 );
456 }
457
458 let is_test = func_name.starts_with("test_");
460
461 if is_test {
462 debug!("Found test function: {}", func_name);
463
464 let mut declared_params: std::collections::HashSet<String> =
466 std::collections::HashSet::new();
467 declared_params.insert("self".to_string());
468 declared_params.insert("request".to_string()); for arg in &args.args {
472 let arg_name = arg.def.arg.as_str();
473 declared_params.insert(arg_name.to_string());
474
475 if arg_name != "self" {
476 let arg_offset = arg.def.range.start().to_usize();
480 let arg_line = self.get_line_from_offset(arg_offset, content);
481 let start_char = self.get_char_position_from_offset(arg_offset, content);
482 let end_char =
483 self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
484
485 debug!(
486 "Parameter {} at offset {}, calculated line {}, char {}",
487 arg_name, arg_offset, arg_line, start_char
488 );
489 info!(
490 "Found fixture usage: {} at {:?}:{}:{}",
491 arg_name, file_path, arg_line, start_char
492 );
493
494 let usage = FixtureUsage {
495 name: arg_name.to_string(),
496 file_path: file_path.clone(),
497 line: arg_line, start_char,
499 end_char,
500 };
501
502 self.usages
504 .entry(file_path.clone())
505 .or_default()
506 .push(usage);
507 }
508 }
509
510 let function_line = self.get_line_from_offset(range.start().to_usize(), content);
512 self.scan_function_body_for_undeclared_fixtures(
513 body,
514 file_path,
515 content,
516 &declared_params,
517 func_name,
518 function_line,
519 );
520 }
521 }
522
523 fn visit_assignment_fixture(
524 &self,
525 assign: &rustpython_parser::ast::StmtAssign,
526 file_path: &PathBuf,
527 content: &str,
528 ) {
529 if let Expr::Call(outer_call) = &*assign.value {
533 if let Expr::Call(inner_call) = &*outer_call.func {
535 if Self::is_fixture_decorator(&inner_call.func) {
536 for target in &assign.targets {
539 if let Expr::Name(name) = target {
540 let fixture_name = name.id.as_str();
541 let line =
542 self.get_line_from_offset(assign.range.start().to_usize(), content);
543
544 info!(
545 "Found fixture assignment: {} at {:?}:{}",
546 fixture_name, file_path, line
547 );
548
549 let definition = FixtureDefinition {
551 name: fixture_name.to_string(),
552 file_path: file_path.clone(),
553 line,
554 docstring: None,
555 };
556
557 self.definitions
558 .entry(fixture_name.to_string())
559 .or_default()
560 .push(definition);
561 }
562 }
563 }
564 }
565 }
566 }
567
568 fn is_fixture_decorator(expr: &Expr) -> bool {
569 match expr {
570 Expr::Name(name) => name.id.as_str() == "fixture",
571 Expr::Attribute(attr) => {
572 if let Expr::Name(value) = &*attr.value {
574 value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
575 } else {
576 false
577 }
578 }
579 Expr::Call(call) => {
580 Self::is_fixture_decorator(&call.func)
582 }
583 _ => false,
584 }
585 }
586
587 fn scan_function_body_for_undeclared_fixtures(
588 &self,
589 body: &[Stmt],
590 file_path: &PathBuf,
591 content: &str,
592 declared_params: &std::collections::HashSet<String>,
593 function_name: &str,
594 function_line: usize,
595 ) {
596 let mut local_vars = std::collections::HashMap::new();
598 self.collect_local_variables(body, content, &mut local_vars);
599
600 if let Some(imports) = self.imports.get(file_path) {
603 for import in imports.iter() {
604 local_vars.insert(import.clone(), 0);
605 }
606 }
607
608 for stmt in body {
610 self.visit_stmt_for_names(
611 stmt,
612 file_path,
613 content,
614 declared_params,
615 &local_vars,
616 function_name,
617 function_line,
618 );
619 }
620 }
621
622 fn collect_module_level_names(
623 &self,
624 stmt: &Stmt,
625 names: &mut std::collections::HashSet<String>,
626 ) {
627 match stmt {
628 Stmt::Import(import_stmt) => {
630 for alias in &import_stmt.names {
631 let name = alias.asname.as_ref().unwrap_or(&alias.name);
633 names.insert(name.to_string());
634 }
635 }
636 Stmt::ImportFrom(import_from) => {
637 for alias in &import_from.names {
638 let name = alias.asname.as_ref().unwrap_or(&alias.name);
640 names.insert(name.to_string());
641 }
642 }
643 Stmt::FunctionDef(func_def) => {
645 let is_fixture = func_def
647 .decorator_list
648 .iter()
649 .any(Self::is_fixture_decorator);
650 if !is_fixture {
651 names.insert(func_def.name.to_string());
652 }
653 }
654 Stmt::AsyncFunctionDef(func_def) => {
656 let is_fixture = func_def
657 .decorator_list
658 .iter()
659 .any(Self::is_fixture_decorator);
660 if !is_fixture {
661 names.insert(func_def.name.to_string());
662 }
663 }
664 Stmt::ClassDef(class_def) => {
666 names.insert(class_def.name.to_string());
667 }
668 Stmt::Assign(assign) => {
670 for target in &assign.targets {
671 self.collect_names_from_expr(target, names);
672 }
673 }
674 Stmt::AnnAssign(ann_assign) => {
675 self.collect_names_from_expr(&ann_assign.target, names);
676 }
677 _ => {}
678 }
679 }
680
681 fn collect_local_variables(
682 &self,
683 body: &[Stmt],
684 content: &str,
685 local_vars: &mut std::collections::HashMap<String, usize>,
686 ) {
687 for stmt in body {
688 match stmt {
689 Stmt::Assign(assign) => {
690 let line = self.get_line_from_offset(assign.range.start().to_usize(), content);
692 let mut temp_names = std::collections::HashSet::new();
693 for target in &assign.targets {
694 self.collect_names_from_expr(target, &mut temp_names);
695 }
696 for name in temp_names {
697 local_vars.insert(name, line);
698 }
699 }
700 Stmt::AnnAssign(ann_assign) => {
701 let line =
703 self.get_line_from_offset(ann_assign.range.start().to_usize(), content);
704 let mut temp_names = std::collections::HashSet::new();
705 self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
706 for name in temp_names {
707 local_vars.insert(name, line);
708 }
709 }
710 Stmt::AugAssign(aug_assign) => {
711 let line =
713 self.get_line_from_offset(aug_assign.range.start().to_usize(), content);
714 let mut temp_names = std::collections::HashSet::new();
715 self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
716 for name in temp_names {
717 local_vars.insert(name, line);
718 }
719 }
720 Stmt::For(for_stmt) => {
721 let line =
723 self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
724 let mut temp_names = std::collections::HashSet::new();
725 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
726 for name in temp_names {
727 local_vars.insert(name, line);
728 }
729 self.collect_local_variables(&for_stmt.body, content, local_vars);
731 }
732 Stmt::AsyncFor(for_stmt) => {
733 let line =
734 self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
735 let mut temp_names = std::collections::HashSet::new();
736 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
737 for name in temp_names {
738 local_vars.insert(name, line);
739 }
740 self.collect_local_variables(&for_stmt.body, content, local_vars);
741 }
742 Stmt::While(while_stmt) => {
743 self.collect_local_variables(&while_stmt.body, content, local_vars);
744 }
745 Stmt::If(if_stmt) => {
746 self.collect_local_variables(&if_stmt.body, content, local_vars);
747 self.collect_local_variables(&if_stmt.orelse, content, local_vars);
748 }
749 Stmt::With(with_stmt) => {
750 let line =
752 self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
753 for item in &with_stmt.items {
754 if let Some(ref optional_vars) = item.optional_vars {
755 let mut temp_names = std::collections::HashSet::new();
756 self.collect_names_from_expr(optional_vars, &mut temp_names);
757 for name in temp_names {
758 local_vars.insert(name, line);
759 }
760 }
761 }
762 self.collect_local_variables(&with_stmt.body, content, local_vars);
763 }
764 Stmt::AsyncWith(with_stmt) => {
765 let line =
766 self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
767 for item in &with_stmt.items {
768 if let Some(ref optional_vars) = item.optional_vars {
769 let mut temp_names = std::collections::HashSet::new();
770 self.collect_names_from_expr(optional_vars, &mut temp_names);
771 for name in temp_names {
772 local_vars.insert(name, line);
773 }
774 }
775 }
776 self.collect_local_variables(&with_stmt.body, content, local_vars);
777 }
778 Stmt::Try(try_stmt) => {
779 self.collect_local_variables(&try_stmt.body, content, local_vars);
780 self.collect_local_variables(&try_stmt.orelse, content, local_vars);
783 self.collect_local_variables(&try_stmt.finalbody, content, local_vars);
784 }
785 _ => {}
786 }
787 }
788 }
789
790 #[allow(clippy::only_used_in_recursion)]
791 fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
792 match expr {
793 Expr::Name(name) => {
794 names.insert(name.id.to_string());
795 }
796 Expr::Tuple(tuple) => {
797 for elt in &tuple.elts {
798 self.collect_names_from_expr(elt, names);
799 }
800 }
801 Expr::List(list) => {
802 for elt in &list.elts {
803 self.collect_names_from_expr(elt, names);
804 }
805 }
806 _ => {}
807 }
808 }
809
810 #[allow(clippy::too_many_arguments)]
811 fn visit_stmt_for_names(
812 &self,
813 stmt: &Stmt,
814 file_path: &PathBuf,
815 content: &str,
816 declared_params: &std::collections::HashSet<String>,
817 local_vars: &std::collections::HashMap<String, usize>,
818 function_name: &str,
819 function_line: usize,
820 ) {
821 match stmt {
822 Stmt::Expr(expr_stmt) => {
823 self.visit_expr_for_names(
824 &expr_stmt.value,
825 file_path,
826 content,
827 declared_params,
828 local_vars,
829 function_name,
830 function_line,
831 );
832 }
833 Stmt::Assign(assign) => {
834 self.visit_expr_for_names(
835 &assign.value,
836 file_path,
837 content,
838 declared_params,
839 local_vars,
840 function_name,
841 function_line,
842 );
843 }
844 Stmt::AugAssign(aug_assign) => {
845 self.visit_expr_for_names(
846 &aug_assign.value,
847 file_path,
848 content,
849 declared_params,
850 local_vars,
851 function_name,
852 function_line,
853 );
854 }
855 Stmt::Return(ret) => {
856 if let Some(ref value) = ret.value {
857 self.visit_expr_for_names(
858 value,
859 file_path,
860 content,
861 declared_params,
862 local_vars,
863 function_name,
864 function_line,
865 );
866 }
867 }
868 Stmt::If(if_stmt) => {
869 self.visit_expr_for_names(
870 &if_stmt.test,
871 file_path,
872 content,
873 declared_params,
874 local_vars,
875 function_name,
876 function_line,
877 );
878 for stmt in &if_stmt.body {
879 self.visit_stmt_for_names(
880 stmt,
881 file_path,
882 content,
883 declared_params,
884 local_vars,
885 function_name,
886 function_line,
887 );
888 }
889 for stmt in &if_stmt.orelse {
890 self.visit_stmt_for_names(
891 stmt,
892 file_path,
893 content,
894 declared_params,
895 local_vars,
896 function_name,
897 function_line,
898 );
899 }
900 }
901 Stmt::While(while_stmt) => {
902 self.visit_expr_for_names(
903 &while_stmt.test,
904 file_path,
905 content,
906 declared_params,
907 local_vars,
908 function_name,
909 function_line,
910 );
911 for stmt in &while_stmt.body {
912 self.visit_stmt_for_names(
913 stmt,
914 file_path,
915 content,
916 declared_params,
917 local_vars,
918 function_name,
919 function_line,
920 );
921 }
922 }
923 Stmt::For(for_stmt) => {
924 self.visit_expr_for_names(
925 &for_stmt.iter,
926 file_path,
927 content,
928 declared_params,
929 local_vars,
930 function_name,
931 function_line,
932 );
933 for stmt in &for_stmt.body {
934 self.visit_stmt_for_names(
935 stmt,
936 file_path,
937 content,
938 declared_params,
939 local_vars,
940 function_name,
941 function_line,
942 );
943 }
944 }
945 Stmt::With(with_stmt) => {
946 for item in &with_stmt.items {
947 self.visit_expr_for_names(
948 &item.context_expr,
949 file_path,
950 content,
951 declared_params,
952 local_vars,
953 function_name,
954 function_line,
955 );
956 }
957 for stmt in &with_stmt.body {
958 self.visit_stmt_for_names(
959 stmt,
960 file_path,
961 content,
962 declared_params,
963 local_vars,
964 function_name,
965 function_line,
966 );
967 }
968 }
969 Stmt::AsyncFor(for_stmt) => {
970 self.visit_expr_for_names(
971 &for_stmt.iter,
972 file_path,
973 content,
974 declared_params,
975 local_vars,
976 function_name,
977 function_line,
978 );
979 for stmt in &for_stmt.body {
980 self.visit_stmt_for_names(
981 stmt,
982 file_path,
983 content,
984 declared_params,
985 local_vars,
986 function_name,
987 function_line,
988 );
989 }
990 }
991 Stmt::AsyncWith(with_stmt) => {
992 for item in &with_stmt.items {
993 self.visit_expr_for_names(
994 &item.context_expr,
995 file_path,
996 content,
997 declared_params,
998 local_vars,
999 function_name,
1000 function_line,
1001 );
1002 }
1003 for stmt in &with_stmt.body {
1004 self.visit_stmt_for_names(
1005 stmt,
1006 file_path,
1007 content,
1008 declared_params,
1009 local_vars,
1010 function_name,
1011 function_line,
1012 );
1013 }
1014 }
1015 Stmt::Assert(assert_stmt) => {
1016 self.visit_expr_for_names(
1017 &assert_stmt.test,
1018 file_path,
1019 content,
1020 declared_params,
1021 local_vars,
1022 function_name,
1023 function_line,
1024 );
1025 if let Some(ref msg) = assert_stmt.msg {
1026 self.visit_expr_for_names(
1027 msg,
1028 file_path,
1029 content,
1030 declared_params,
1031 local_vars,
1032 function_name,
1033 function_line,
1034 );
1035 }
1036 }
1037 _ => {} }
1039 }
1040
1041 #[allow(clippy::too_many_arguments)]
1042 fn visit_expr_for_names(
1043 &self,
1044 expr: &Expr,
1045 file_path: &PathBuf,
1046 content: &str,
1047 declared_params: &std::collections::HashSet<String>,
1048 local_vars: &std::collections::HashMap<String, usize>,
1049 function_name: &str,
1050 function_line: usize,
1051 ) {
1052 match expr {
1053 Expr::Name(name) => {
1054 let name_str = name.id.as_str();
1055 let line = self.get_line_from_offset(name.range.start().to_usize(), content);
1056
1057 let is_local_var_in_scope = local_vars
1061 .get(name_str)
1062 .map(|def_line| *def_line < line)
1063 .unwrap_or(false);
1064
1065 if !declared_params.contains(name_str)
1066 && !is_local_var_in_scope
1067 && self.is_available_fixture(file_path, name_str)
1068 {
1069 let start_char =
1070 self.get_char_position_from_offset(name.range.start().to_usize(), content);
1071 let end_char =
1072 self.get_char_position_from_offset(name.range.end().to_usize(), content);
1073
1074 info!(
1075 "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1076 name_str, file_path, line, start_char, function_name
1077 );
1078
1079 let undeclared = UndeclaredFixture {
1080 name: name_str.to_string(),
1081 file_path: file_path.clone(),
1082 line,
1083 start_char,
1084 end_char,
1085 function_name: function_name.to_string(),
1086 function_line,
1087 };
1088
1089 self.undeclared_fixtures
1090 .entry(file_path.clone())
1091 .or_default()
1092 .push(undeclared);
1093 }
1094 }
1095 Expr::Call(call) => {
1096 self.visit_expr_for_names(
1097 &call.func,
1098 file_path,
1099 content,
1100 declared_params,
1101 local_vars,
1102 function_name,
1103 function_line,
1104 );
1105 for arg in &call.args {
1106 self.visit_expr_for_names(
1107 arg,
1108 file_path,
1109 content,
1110 declared_params,
1111 local_vars,
1112 function_name,
1113 function_line,
1114 );
1115 }
1116 }
1117 Expr::Attribute(attr) => {
1118 self.visit_expr_for_names(
1119 &attr.value,
1120 file_path,
1121 content,
1122 declared_params,
1123 local_vars,
1124 function_name,
1125 function_line,
1126 );
1127 }
1128 Expr::BinOp(binop) => {
1129 self.visit_expr_for_names(
1130 &binop.left,
1131 file_path,
1132 content,
1133 declared_params,
1134 local_vars,
1135 function_name,
1136 function_line,
1137 );
1138 self.visit_expr_for_names(
1139 &binop.right,
1140 file_path,
1141 content,
1142 declared_params,
1143 local_vars,
1144 function_name,
1145 function_line,
1146 );
1147 }
1148 Expr::UnaryOp(unaryop) => {
1149 self.visit_expr_for_names(
1150 &unaryop.operand,
1151 file_path,
1152 content,
1153 declared_params,
1154 local_vars,
1155 function_name,
1156 function_line,
1157 );
1158 }
1159 Expr::Compare(compare) => {
1160 self.visit_expr_for_names(
1161 &compare.left,
1162 file_path,
1163 content,
1164 declared_params,
1165 local_vars,
1166 function_name,
1167 function_line,
1168 );
1169 for comparator in &compare.comparators {
1170 self.visit_expr_for_names(
1171 comparator,
1172 file_path,
1173 content,
1174 declared_params,
1175 local_vars,
1176 function_name,
1177 function_line,
1178 );
1179 }
1180 }
1181 Expr::Subscript(subscript) => {
1182 self.visit_expr_for_names(
1183 &subscript.value,
1184 file_path,
1185 content,
1186 declared_params,
1187 local_vars,
1188 function_name,
1189 function_line,
1190 );
1191 self.visit_expr_for_names(
1192 &subscript.slice,
1193 file_path,
1194 content,
1195 declared_params,
1196 local_vars,
1197 function_name,
1198 function_line,
1199 );
1200 }
1201 Expr::List(list) => {
1202 for elt in &list.elts {
1203 self.visit_expr_for_names(
1204 elt,
1205 file_path,
1206 content,
1207 declared_params,
1208 local_vars,
1209 function_name,
1210 function_line,
1211 );
1212 }
1213 }
1214 Expr::Tuple(tuple) => {
1215 for elt in &tuple.elts {
1216 self.visit_expr_for_names(
1217 elt,
1218 file_path,
1219 content,
1220 declared_params,
1221 local_vars,
1222 function_name,
1223 function_line,
1224 );
1225 }
1226 }
1227 Expr::Dict(dict) => {
1228 for k in dict.keys.iter().flatten() {
1229 self.visit_expr_for_names(
1230 k,
1231 file_path,
1232 content,
1233 declared_params,
1234 local_vars,
1235 function_name,
1236 function_line,
1237 );
1238 }
1239 for value in &dict.values {
1240 self.visit_expr_for_names(
1241 value,
1242 file_path,
1243 content,
1244 declared_params,
1245 local_vars,
1246 function_name,
1247 function_line,
1248 );
1249 }
1250 }
1251 Expr::Await(await_expr) => {
1252 self.visit_expr_for_names(
1254 &await_expr.value,
1255 file_path,
1256 content,
1257 declared_params,
1258 local_vars,
1259 function_name,
1260 function_line,
1261 );
1262 }
1263 _ => {} }
1265 }
1266
1267 fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1268 if let Some(definitions) = self.definitions.get(fixture_name) {
1270 for def in definitions.iter() {
1272 if def.file_path == file_path {
1274 return true;
1275 }
1276
1277 if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1279 && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1280 {
1281 return true;
1282 }
1283
1284 if def.file_path.to_string_lossy().contains("site-packages") {
1286 return true;
1287 }
1288 }
1289 }
1290 false
1291 }
1292
1293 fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1294 if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1296 if let Expr::Constant(constant) = &*expr_stmt.value {
1297 if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1299 return Some(self.format_docstring(s.to_string()));
1300 }
1301 }
1302 }
1303 None
1304 }
1305
1306 fn format_docstring(&self, docstring: String) -> String {
1307 let lines: Vec<&str> = docstring.lines().collect();
1310
1311 if lines.is_empty() {
1312 return String::new();
1313 }
1314
1315 let mut start = 0;
1317 let mut end = lines.len();
1318
1319 while start < lines.len() && lines[start].trim().is_empty() {
1320 start += 1;
1321 }
1322
1323 while end > start && lines[end - 1].trim().is_empty() {
1324 end -= 1;
1325 }
1326
1327 if start >= end {
1328 return String::new();
1329 }
1330
1331 let lines = &lines[start..end];
1332
1333 let mut min_indent = usize::MAX;
1335 for (i, line) in lines.iter().enumerate() {
1336 if i == 0 && !line.trim().is_empty() {
1337 continue;
1339 }
1340
1341 if !line.trim().is_empty() {
1342 let indent = line.len() - line.trim_start().len();
1343 min_indent = min_indent.min(indent);
1344 }
1345 }
1346
1347 if min_indent == usize::MAX {
1348 min_indent = 0;
1349 }
1350
1351 let mut result = Vec::new();
1353 for (i, line) in lines.iter().enumerate() {
1354 if i == 0 {
1355 result.push(line.trim().to_string());
1357 } else if line.trim().is_empty() {
1358 result.push(String::new());
1360 } else {
1361 let dedented = if line.len() > min_indent {
1363 &line[min_indent..]
1364 } else {
1365 line.trim_start()
1366 };
1367 result.push(dedented.to_string());
1368 }
1369 }
1370
1371 result.join("\n")
1373 }
1374
1375 fn get_line_from_offset(&self, offset: usize, content: &str) -> usize {
1376 content[..offset].matches('\n').count() + 1
1378 }
1379
1380 fn get_char_position_from_offset(&self, offset: usize, content: &str) -> usize {
1381 if let Some(line_start) = content[..offset].rfind('\n') {
1383 offset - line_start - 1
1385 } else {
1386 offset
1388 }
1389 }
1390
1391 pub fn find_fixture_definition(
1393 &self,
1394 file_path: &Path,
1395 line: u32,
1396 character: u32,
1397 ) -> Option<FixtureDefinition> {
1398 debug!(
1399 "find_fixture_definition: file={:?}, line={}, char={}",
1400 file_path, line, character
1401 );
1402
1403 let target_line = (line + 1) as usize; let content: Arc<String> = if let Some(cached) = self.file_cache.get(file_path) {
1408 Arc::clone(cached.value())
1409 } else {
1410 Arc::new(std::fs::read_to_string(file_path).ok()?)
1411 };
1412
1413 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1415 debug!("Line content: {}", line_content);
1416
1417 let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1419 debug!("Word at cursor: {:?}", word_at_cursor);
1420
1421 let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1424
1425 if let Some(usages) = self.usages.get(file_path) {
1428 for usage in usages.iter() {
1429 if usage.line == target_line && usage.name == word_at_cursor {
1430 let cursor_pos = character as usize;
1432 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1433 debug!(
1434 "Cursor at {} is within usage range {}-{}: {}",
1435 cursor_pos, usage.start_char, usage.end_char, usage.name
1436 );
1437 info!("Found fixture usage at cursor position: {}", usage.name);
1438
1439 if let Some(ref current_def) = current_fixture_def {
1441 if current_def.name == word_at_cursor {
1442 info!(
1443 "Self-referencing fixture detected, finding parent definition"
1444 );
1445 return self.find_closest_definition_excluding(
1446 file_path,
1447 &usage.name,
1448 Some(current_def),
1449 );
1450 }
1451 }
1452
1453 return self.find_closest_definition(file_path, &usage.name);
1455 }
1456 }
1457 }
1458 }
1459
1460 debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1461 None
1462 }
1463
1464 fn get_fixture_definition_at_line(
1466 &self,
1467 file_path: &Path,
1468 line: usize,
1469 ) -> Option<FixtureDefinition> {
1470 for entry in self.definitions.iter() {
1471 for def in entry.value().iter() {
1472 if def.file_path == file_path && def.line == line {
1473 return Some(def.clone());
1474 }
1475 }
1476 }
1477 None
1478 }
1479
1480 pub fn get_definition_at_line(
1483 &self,
1484 file_path: &Path,
1485 line: usize,
1486 fixture_name: &str,
1487 ) -> Option<FixtureDefinition> {
1488 if let Some(definitions) = self.definitions.get(fixture_name) {
1489 for def in definitions.iter() {
1490 if def.file_path == file_path && def.line == line {
1491 return Some(def.clone());
1492 }
1493 }
1494 }
1495 None
1496 }
1497
1498 fn find_closest_definition(
1499 &self,
1500 file_path: &Path,
1501 fixture_name: &str,
1502 ) -> Option<FixtureDefinition> {
1503 let definitions = self.definitions.get(fixture_name)?;
1504
1505 debug!(
1508 "Checking for fixture {} in same file: {:?}",
1509 fixture_name, file_path
1510 );
1511
1512 if let Some(last_def) = definitions
1514 .iter()
1515 .filter(|def| def.file_path == file_path)
1516 .max_by_key(|def| def.line)
1517 {
1518 info!(
1519 "Found fixture {} in same file at line {} (using last definition)",
1520 fixture_name, last_def.line
1521 );
1522 return Some(last_def.clone());
1523 }
1524
1525 let mut current_dir = file_path.parent()?;
1528
1529 debug!(
1530 "Searching for fixture {} in conftest.py files starting from {:?}",
1531 fixture_name, current_dir
1532 );
1533 loop {
1534 let conftest_path = current_dir.join("conftest.py");
1536 debug!(" Checking conftest.py at: {:?}", conftest_path);
1537
1538 for def in definitions.iter() {
1539 if def.file_path == conftest_path {
1540 info!(
1541 "Found fixture {} in conftest.py: {:?}",
1542 fixture_name, conftest_path
1543 );
1544 return Some(def.clone());
1545 }
1546 }
1547
1548 match current_dir.parent() {
1550 Some(parent) => current_dir = parent,
1551 None => break,
1552 }
1553 }
1554
1555 debug!(
1558 "No fixture {} found in conftest hierarchy, checking for third-party fixtures",
1559 fixture_name
1560 );
1561 for def in definitions.iter() {
1562 if def.file_path.to_string_lossy().contains("site-packages") {
1563 info!(
1564 "Found third-party fixture {} in site-packages: {:?}",
1565 fixture_name, def.file_path
1566 );
1567 return Some(def.clone());
1568 }
1569 }
1570
1571 warn!(
1576 "No fixture {} found following priority rules (same file, conftest hierarchy, third-party)",
1577 fixture_name
1578 );
1579 warn!(
1580 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1581 );
1582
1583 let mut defs: Vec<_> = definitions.iter().cloned().collect();
1584 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1585 defs.first().cloned()
1586 }
1587
1588 fn find_closest_definition_excluding(
1591 &self,
1592 file_path: &Path,
1593 fixture_name: &str,
1594 exclude: Option<&FixtureDefinition>,
1595 ) -> Option<FixtureDefinition> {
1596 let definitions = self.definitions.get(fixture_name)?;
1597
1598 debug!(
1602 "Checking for fixture {} in same file: {:?} (excluding: {:?})",
1603 fixture_name, file_path, exclude
1604 );
1605
1606 if let Some(last_def) = definitions
1608 .iter()
1609 .filter(|def| {
1610 if def.file_path != file_path {
1611 return false;
1612 }
1613 if let Some(excluded) = exclude {
1615 if def == &excluded {
1616 debug!("Skipping excluded definition at line {}", def.line);
1617 return false;
1618 }
1619 }
1620 true
1621 })
1622 .max_by_key(|def| def.line)
1623 {
1624 info!(
1625 "Found fixture {} in same file at line {} (using last definition, excluding specified)",
1626 fixture_name, last_def.line
1627 );
1628 return Some(last_def.clone());
1629 }
1630
1631 let mut current_dir = file_path.parent()?;
1633
1634 debug!(
1635 "Searching for fixture {} in conftest.py files starting from {:?}",
1636 fixture_name, current_dir
1637 );
1638 loop {
1639 let conftest_path = current_dir.join("conftest.py");
1640 debug!(" Checking conftest.py at: {:?}", conftest_path);
1641
1642 for def in definitions.iter() {
1643 if def.file_path == conftest_path {
1644 if let Some(excluded) = exclude {
1646 if def == excluded {
1647 debug!("Skipping excluded definition at line {}", def.line);
1648 continue;
1649 }
1650 }
1651 info!(
1652 "Found fixture {} in conftest.py: {:?}",
1653 fixture_name, conftest_path
1654 );
1655 return Some(def.clone());
1656 }
1657 }
1658
1659 match current_dir.parent() {
1661 Some(parent) => current_dir = parent,
1662 None => break,
1663 }
1664 }
1665
1666 debug!(
1668 "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
1669 fixture_name
1670 );
1671 for def in definitions.iter() {
1672 if let Some(excluded) = exclude {
1674 if def == excluded {
1675 continue;
1676 }
1677 }
1678 if def.file_path.to_string_lossy().contains("site-packages") {
1679 info!(
1680 "Found third-party fixture {} in site-packages: {:?}",
1681 fixture_name, def.file_path
1682 );
1683 return Some(def.clone());
1684 }
1685 }
1686
1687 warn!(
1689 "No fixture {} found following priority rules (excluding specified)",
1690 fixture_name
1691 );
1692 warn!(
1693 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1694 );
1695
1696 let mut defs: Vec<_> = definitions
1697 .iter()
1698 .filter(|def| {
1699 if let Some(excluded) = exclude {
1700 def != &excluded
1701 } else {
1702 true
1703 }
1704 })
1705 .cloned()
1706 .collect();
1707 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1708 defs.first().cloned()
1709 }
1710
1711 pub fn find_fixture_at_position(
1713 &self,
1714 file_path: &Path,
1715 line: u32,
1716 character: u32,
1717 ) -> Option<String> {
1718 let target_line = (line + 1) as usize; debug!(
1721 "find_fixture_at_position: file={:?}, line={}, char={}",
1722 file_path, target_line, character
1723 );
1724
1725 let content: Arc<String> = if let Some(cached) = self.file_cache.get(file_path) {
1728 Arc::clone(cached.value())
1729 } else {
1730 Arc::new(std::fs::read_to_string(file_path).ok()?)
1731 };
1732
1733 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1735 debug!("Line content: {}", line_content);
1736
1737 let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
1739 debug!("Word at cursor: {:?}", word_at_cursor);
1740
1741 if let Some(usages) = self.usages.get(file_path) {
1744 for usage in usages.iter() {
1745 if usage.line == target_line {
1746 let cursor_pos = character as usize;
1748 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1749 debug!(
1750 "Cursor at {} is within usage range {}-{}: {}",
1751 cursor_pos, usage.start_char, usage.end_char, usage.name
1752 );
1753 info!("Found fixture usage at cursor position: {}", usage.name);
1754 return Some(usage.name.clone());
1755 }
1756 }
1757 }
1758 }
1759
1760 for entry in self.definitions.iter() {
1763 for def in entry.value().iter() {
1764 if def.file_path == file_path && def.line == target_line {
1765 if let Some(ref word) = word_at_cursor {
1767 if word == &def.name {
1768 info!(
1769 "Found fixture definition name at cursor position: {}",
1770 def.name
1771 );
1772 return Some(def.name.clone());
1773 }
1774 }
1775 }
1778 }
1779 }
1780
1781 debug!("No fixture found at cursor position");
1782 None
1783 }
1784
1785 fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
1786 let chars: Vec<char> = line.chars().collect();
1787
1788 if character > chars.len() {
1790 return None;
1791 }
1792
1793 if character < chars.len() {
1795 let c = chars[character];
1796 if c.is_alphanumeric() || c == '_' {
1797 let mut start = character;
1799 while start > 0 {
1800 let prev_c = chars[start - 1];
1801 if !prev_c.is_alphanumeric() && prev_c != '_' {
1802 break;
1803 }
1804 start -= 1;
1805 }
1806
1807 let mut end = character;
1808 while end < chars.len() {
1809 let curr_c = chars[end];
1810 if !curr_c.is_alphanumeric() && curr_c != '_' {
1811 break;
1812 }
1813 end += 1;
1814 }
1815
1816 if start < end {
1817 return Some(chars[start..end].iter().collect());
1818 }
1819 }
1820 }
1821
1822 None
1823 }
1824
1825 pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
1827 info!("Finding all references for fixture: {}", fixture_name);
1828
1829 let mut all_references = Vec::new();
1830
1831 for entry in self.usages.iter() {
1833 let file_path = entry.key();
1834 let usages = entry.value();
1835
1836 for usage in usages.iter() {
1838 if usage.name == fixture_name {
1839 debug!(
1840 "Found reference to {} in {:?} at line {}",
1841 fixture_name, file_path, usage.line
1842 );
1843 all_references.push(usage.clone());
1844 }
1845 }
1846 }
1847
1848 info!(
1849 "Found {} total references for fixture: {}",
1850 all_references.len(),
1851 fixture_name
1852 );
1853 all_references
1854 }
1855
1856 pub fn find_references_for_definition(
1863 &self,
1864 definition: &FixtureDefinition,
1865 ) -> Vec<FixtureUsage> {
1866 info!(
1867 "Finding references for specific definition: {} at {:?}:{}",
1868 definition.name, definition.file_path, definition.line
1869 );
1870
1871 let mut matching_references = Vec::new();
1872
1873 for entry in self.usages.iter() {
1875 let file_path = entry.key();
1876 let usages = entry.value();
1877
1878 for usage in usages.iter() {
1879 if usage.name == definition.name {
1880 let fixture_def_at_line =
1883 self.get_fixture_definition_at_line(file_path, usage.line);
1884
1885 let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
1886 if current_def.name == usage.name {
1887 debug!(
1889 "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
1890 file_path, usage.line, current_def.line
1891 );
1892 self.find_closest_definition_excluding(
1893 file_path,
1894 &usage.name,
1895 Some(current_def),
1896 )
1897 } else {
1898 self.find_closest_definition(file_path, &usage.name)
1900 }
1901 } else {
1902 self.find_closest_definition(file_path, &usage.name)
1904 };
1905
1906 if let Some(resolved_def) = resolved_def {
1907 if resolved_def == *definition {
1908 debug!(
1909 "Usage at {:?}:{} resolves to our definition",
1910 file_path, usage.line
1911 );
1912 matching_references.push(usage.clone());
1913 } else {
1914 debug!(
1915 "Usage at {:?}:{} resolves to different definition at {:?}:{}",
1916 file_path, usage.line, resolved_def.file_path, resolved_def.line
1917 );
1918 }
1919 }
1920 }
1921 }
1922 }
1923
1924 info!(
1925 "Found {} references that resolve to this specific definition",
1926 matching_references.len()
1927 );
1928 matching_references
1929 }
1930
1931 pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
1933 self.undeclared_fixtures
1934 .get(file_path)
1935 .map(|entry| entry.value().clone())
1936 .unwrap_or_default()
1937 }
1938}
1939
1940#[cfg(test)]
1941mod tests {
1942 use super::*;
1943 use std::path::PathBuf;
1944
1945 #[test]
1946 fn test_fixture_definition_detection() {
1947 let db = FixtureDatabase::new();
1948
1949 let conftest_content = r#"
1950import pytest
1951
1952@pytest.fixture
1953def my_fixture():
1954 return 42
1955
1956@fixture
1957def another_fixture():
1958 return "hello"
1959"#;
1960
1961 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
1962 db.analyze_file(conftest_path.clone(), conftest_content);
1963
1964 assert!(db.definitions.contains_key("my_fixture"));
1966 assert!(db.definitions.contains_key("another_fixture"));
1967
1968 let my_fixture_defs = db.definitions.get("my_fixture").unwrap();
1970 assert_eq!(my_fixture_defs.len(), 1);
1971 assert_eq!(my_fixture_defs[0].name, "my_fixture");
1972 assert_eq!(my_fixture_defs[0].file_path, conftest_path);
1973 }
1974
1975 #[test]
1976 fn test_fixture_usage_detection() {
1977 let db = FixtureDatabase::new();
1978
1979 let test_content = r#"
1980def test_something(my_fixture, another_fixture):
1981 assert my_fixture == 42
1982 assert another_fixture == "hello"
1983
1984def test_other(my_fixture):
1985 assert my_fixture > 0
1986"#;
1987
1988 let test_path = PathBuf::from("/tmp/test/test_example.py");
1989 db.analyze_file(test_path.clone(), test_content);
1990
1991 assert!(db.usages.contains_key(&test_path));
1993
1994 let usages = db.usages.get(&test_path).unwrap();
1995 assert!(usages.iter().any(|u| u.name == "my_fixture"));
1997 assert!(usages.iter().any(|u| u.name == "another_fixture"));
1998 }
1999
2000 #[test]
2001 fn test_go_to_definition() {
2002 let db = FixtureDatabase::new();
2003
2004 let conftest_content = r#"
2006import pytest
2007
2008@pytest.fixture
2009def my_fixture():
2010 return 42
2011"#;
2012
2013 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2014 db.analyze_file(conftest_path.clone(), conftest_content);
2015
2016 let test_content = r#"
2018def test_something(my_fixture):
2019 assert my_fixture == 42
2020"#;
2021
2022 let test_path = PathBuf::from("/tmp/test/test_example.py");
2023 db.analyze_file(test_path.clone(), test_content);
2024
2025 let definition = db.find_fixture_definition(&test_path, 1, 19);
2030
2031 assert!(definition.is_some(), "Definition should be found");
2032 let def = definition.unwrap();
2033 assert_eq!(def.name, "my_fixture");
2034 assert_eq!(def.file_path, conftest_path);
2035 }
2036
2037 #[test]
2038 fn test_fixture_decorator_variations() {
2039 let db = FixtureDatabase::new();
2040
2041 let conftest_content = r#"
2042import pytest
2043from pytest import fixture
2044
2045@pytest.fixture
2046def fixture1():
2047 pass
2048
2049@pytest.fixture()
2050def fixture2():
2051 pass
2052
2053@fixture
2054def fixture3():
2055 pass
2056
2057@fixture()
2058def fixture4():
2059 pass
2060"#;
2061
2062 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2063 db.analyze_file(conftest_path, conftest_content);
2064
2065 assert!(db.definitions.contains_key("fixture1"));
2067 assert!(db.definitions.contains_key("fixture2"));
2068 assert!(db.definitions.contains_key("fixture3"));
2069 assert!(db.definitions.contains_key("fixture4"));
2070 }
2071
2072 #[test]
2073 fn test_fixture_in_test_file() {
2074 let db = FixtureDatabase::new();
2075
2076 let test_content = r#"
2078import pytest
2079
2080@pytest.fixture
2081def local_fixture():
2082 return 42
2083
2084def test_something(local_fixture):
2085 assert local_fixture == 42
2086"#;
2087
2088 let test_path = PathBuf::from("/tmp/test/test_example.py");
2089 db.analyze_file(test_path.clone(), test_content);
2090
2091 assert!(db.definitions.contains_key("local_fixture"));
2093
2094 let local_fixture_defs = db.definitions.get("local_fixture").unwrap();
2095 assert_eq!(local_fixture_defs.len(), 1);
2096 assert_eq!(local_fixture_defs[0].name, "local_fixture");
2097 assert_eq!(local_fixture_defs[0].file_path, test_path);
2098
2099 assert!(db.usages.contains_key(&test_path));
2101 let usages = db.usages.get(&test_path).unwrap();
2102 assert!(usages.iter().any(|u| u.name == "local_fixture"));
2103
2104 let usage_line = usages
2106 .iter()
2107 .find(|u| u.name == "local_fixture")
2108 .map(|u| u.line)
2109 .unwrap();
2110
2111 let definition = db.find_fixture_definition(&test_path, (usage_line - 1) as u32, 19);
2113 assert!(
2114 definition.is_some(),
2115 "Should find definition for fixture in same file. Line: {}, char: 19",
2116 usage_line
2117 );
2118 let def = definition.unwrap();
2119 assert_eq!(def.name, "local_fixture");
2120 assert_eq!(def.file_path, test_path);
2121 }
2122
2123 #[test]
2124 fn test_async_test_functions() {
2125 let db = FixtureDatabase::new();
2126
2127 let test_content = r#"
2129import pytest
2130
2131@pytest.fixture
2132def my_fixture():
2133 return 42
2134
2135async def test_async_function(my_fixture):
2136 assert my_fixture == 42
2137
2138def test_sync_function(my_fixture):
2139 assert my_fixture == 42
2140"#;
2141
2142 let test_path = PathBuf::from("/tmp/test/test_async.py");
2143 db.analyze_file(test_path.clone(), test_content);
2144
2145 assert!(db.definitions.contains_key("my_fixture"));
2147
2148 assert!(db.usages.contains_key(&test_path));
2150 let usages = db.usages.get(&test_path).unwrap();
2151
2152 let fixture_usages: Vec<_> = usages.iter().filter(|u| u.name == "my_fixture").collect();
2154 assert_eq!(
2155 fixture_usages.len(),
2156 2,
2157 "Should detect fixture usage in both async and sync tests"
2158 );
2159 }
2160
2161 #[test]
2162 fn test_extract_word_at_position() {
2163 let db = FixtureDatabase::new();
2164
2165 let line = "def test_something(my_fixture):";
2167
2168 assert_eq!(
2170 db.extract_word_at_position(line, 19),
2171 Some("my_fixture".to_string())
2172 );
2173
2174 assert_eq!(
2176 db.extract_word_at_position(line, 20),
2177 Some("my_fixture".to_string())
2178 );
2179
2180 assert_eq!(
2182 db.extract_word_at_position(line, 28),
2183 Some("my_fixture".to_string())
2184 );
2185
2186 assert_eq!(
2188 db.extract_word_at_position(line, 0),
2189 Some("def".to_string())
2190 );
2191
2192 assert_eq!(db.extract_word_at_position(line, 3), None);
2194
2195 assert_eq!(
2197 db.extract_word_at_position(line, 4),
2198 Some("test_something".to_string())
2199 );
2200
2201 assert_eq!(db.extract_word_at_position(line, 18), None);
2203
2204 assert_eq!(db.extract_word_at_position(line, 29), None);
2206
2207 assert_eq!(db.extract_word_at_position(line, 31), None);
2209 }
2210
2211 #[test]
2212 fn test_extract_word_at_position_fixture_definition() {
2213 let db = FixtureDatabase::new();
2214
2215 let line = "@pytest.fixture";
2216
2217 assert_eq!(db.extract_word_at_position(line, 0), None);
2219
2220 assert_eq!(
2222 db.extract_word_at_position(line, 1),
2223 Some("pytest".to_string())
2224 );
2225
2226 assert_eq!(db.extract_word_at_position(line, 7), None);
2228
2229 assert_eq!(
2231 db.extract_word_at_position(line, 8),
2232 Some("fixture".to_string())
2233 );
2234
2235 let line2 = "def foo(other_fixture):";
2236
2237 assert_eq!(
2239 db.extract_word_at_position(line2, 0),
2240 Some("def".to_string())
2241 );
2242
2243 assert_eq!(db.extract_word_at_position(line2, 3), None);
2245
2246 assert_eq!(
2248 db.extract_word_at_position(line2, 4),
2249 Some("foo".to_string())
2250 );
2251
2252 assert_eq!(
2254 db.extract_word_at_position(line2, 8),
2255 Some("other_fixture".to_string())
2256 );
2257
2258 assert_eq!(db.extract_word_at_position(line2, 7), None);
2260 }
2261
2262 #[test]
2263 fn test_word_detection_only_on_fixtures() {
2264 let db = FixtureDatabase::new();
2265
2266 let conftest_content = r#"
2268import pytest
2269
2270@pytest.fixture
2271def my_fixture():
2272 return 42
2273"#;
2274 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2275 db.analyze_file(conftest_path.clone(), conftest_content);
2276
2277 let test_content = r#"
2279def test_something(my_fixture, regular_param):
2280 assert my_fixture == 42
2281"#;
2282 let test_path = PathBuf::from("/tmp/test/test_example.py");
2283 db.analyze_file(test_path.clone(), test_content);
2284
2285 assert_eq!(db.find_fixture_definition(&test_path, 1, 0), None);
2294
2295 assert_eq!(db.find_fixture_definition(&test_path, 1, 4), None);
2297
2298 let result = db.find_fixture_definition(&test_path, 1, 19);
2300 assert!(result.is_some());
2301 let def = result.unwrap();
2302 assert_eq!(def.name, "my_fixture");
2303
2304 assert_eq!(db.find_fixture_definition(&test_path, 1, 31), None);
2306
2307 assert_eq!(db.find_fixture_definition(&test_path, 1, 18), None); assert_eq!(db.find_fixture_definition(&test_path, 1, 29), None); }
2311
2312 #[test]
2313 fn test_self_referencing_fixture() {
2314 let db = FixtureDatabase::new();
2315
2316 let parent_conftest_content = r#"
2318import pytest
2319
2320@pytest.fixture
2321def foo():
2322 return "parent"
2323"#;
2324 let parent_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2325 db.analyze_file(parent_conftest_path.clone(), parent_conftest_content);
2326
2327 let child_conftest_content = r#"
2329import pytest
2330
2331@pytest.fixture
2332def foo(foo):
2333 return foo + " child"
2334"#;
2335 let child_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2336 db.analyze_file(child_conftest_path.clone(), child_conftest_content);
2337
2338 let result = db.find_fixture_definition(&child_conftest_path, 4, 8);
2344
2345 assert!(
2346 result.is_some(),
2347 "Should find parent definition for self-referencing fixture"
2348 );
2349 let def = result.unwrap();
2350 assert_eq!(def.name, "foo");
2351 assert_eq!(
2352 def.file_path, parent_conftest_path,
2353 "Should resolve to parent conftest.py, not the child"
2354 );
2355 assert_eq!(def.line, 5, "Should point to line 5 of parent conftest.py");
2356 }
2357
2358 #[test]
2359 fn test_fixture_overriding_same_file() {
2360 let db = FixtureDatabase::new();
2361
2362 let test_content = r#"
2364import pytest
2365
2366@pytest.fixture
2367def my_fixture():
2368 return "first"
2369
2370@pytest.fixture
2371def my_fixture():
2372 return "second"
2373
2374def test_something(my_fixture):
2375 assert my_fixture == "second"
2376"#;
2377 let test_path = PathBuf::from("/tmp/test/test_example.py");
2378 db.analyze_file(test_path.clone(), test_content);
2379
2380 let result = db.find_fixture_definition(&test_path, 11, 19);
2389
2390 assert!(result.is_some(), "Should find fixture definition");
2391 let def = result.unwrap();
2392 assert_eq!(def.name, "my_fixture");
2393 assert_eq!(def.file_path, test_path);
2394 }
2398
2399 #[test]
2400 fn test_fixture_overriding_conftest_hierarchy() {
2401 let db = FixtureDatabase::new();
2402
2403 let root_conftest_content = r#"
2405import pytest
2406
2407@pytest.fixture
2408def shared_fixture():
2409 return "root"
2410"#;
2411 let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2412 db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2413
2414 let sub_conftest_content = r#"
2416import pytest
2417
2418@pytest.fixture
2419def shared_fixture():
2420 return "subdir"
2421"#;
2422 let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2423 db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2424
2425 let test_content = r#"
2427def test_something(shared_fixture):
2428 assert shared_fixture == "subdir"
2429"#;
2430 let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
2431 db.analyze_file(test_path.clone(), test_content);
2432
2433 let result = db.find_fixture_definition(&test_path, 1, 19);
2439
2440 assert!(result.is_some(), "Should find fixture definition");
2441 let def = result.unwrap();
2442 assert_eq!(def.name, "shared_fixture");
2443 assert_eq!(
2444 def.file_path, sub_conftest_path,
2445 "Should resolve to closest conftest.py"
2446 );
2447
2448 let parent_test_content = r#"
2450def test_parent(shared_fixture):
2451 assert shared_fixture == "root"
2452"#;
2453 let parent_test_path = PathBuf::from("/tmp/test/test_parent.py");
2454 db.analyze_file(parent_test_path.clone(), parent_test_content);
2455
2456 let result = db.find_fixture_definition(&parent_test_path, 1, 16);
2457
2458 assert!(result.is_some(), "Should find fixture definition");
2459 let def = result.unwrap();
2460 assert_eq!(def.name, "shared_fixture");
2461 assert_eq!(
2462 def.file_path, root_conftest_path,
2463 "Should resolve to root conftest.py"
2464 );
2465 }
2466
2467 #[test]
2468 fn test_scoped_references() {
2469 let db = FixtureDatabase::new();
2470
2471 let root_conftest_content = r#"
2473import pytest
2474
2475@pytest.fixture
2476def shared_fixture():
2477 return "root"
2478"#;
2479 let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2480 db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2481
2482 let sub_conftest_content = r#"
2484import pytest
2485
2486@pytest.fixture
2487def shared_fixture():
2488 return "subdir"
2489"#;
2490 let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2491 db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2492
2493 let root_test_content = r#"
2495def test_root(shared_fixture):
2496 assert shared_fixture == "root"
2497"#;
2498 let root_test_path = PathBuf::from("/tmp/test/test_root.py");
2499 db.analyze_file(root_test_path.clone(), root_test_content);
2500
2501 let sub_test_content = r#"
2503def test_sub(shared_fixture):
2504 assert shared_fixture == "subdir"
2505"#;
2506 let sub_test_path = PathBuf::from("/tmp/test/subdir/test_sub.py");
2507 db.analyze_file(sub_test_path.clone(), sub_test_content);
2508
2509 let sub_test2_content = r#"
2511def test_sub2(shared_fixture):
2512 assert shared_fixture == "subdir"
2513"#;
2514 let sub_test2_path = PathBuf::from("/tmp/test/subdir/test_sub2.py");
2515 db.analyze_file(sub_test2_path.clone(), sub_test2_content);
2516
2517 let root_definitions = db.definitions.get("shared_fixture").unwrap();
2519 let root_definition = root_definitions
2520 .iter()
2521 .find(|d| d.file_path == root_conftest_path)
2522 .unwrap();
2523
2524 let sub_definition = root_definitions
2526 .iter()
2527 .find(|d| d.file_path == sub_conftest_path)
2528 .unwrap();
2529
2530 let root_refs = db.find_references_for_definition(root_definition);
2532
2533 assert_eq!(
2535 root_refs.len(),
2536 1,
2537 "Root definition should have 1 reference (from root test)"
2538 );
2539 assert_eq!(root_refs[0].file_path, root_test_path);
2540
2541 let sub_refs = db.find_references_for_definition(sub_definition);
2543
2544 assert_eq!(
2546 sub_refs.len(),
2547 2,
2548 "Subdir definition should have 2 references (from subdir tests)"
2549 );
2550
2551 let sub_ref_paths: Vec<_> = sub_refs.iter().map(|r| &r.file_path).collect();
2552 assert!(sub_ref_paths.contains(&&sub_test_path));
2553 assert!(sub_ref_paths.contains(&&sub_test2_path));
2554
2555 let all_refs = db.find_fixture_references("shared_fixture");
2557 assert_eq!(
2558 all_refs.len(),
2559 3,
2560 "Should find 3 total references across all scopes"
2561 );
2562 }
2563
2564 #[test]
2565 fn test_multiline_parameters() {
2566 let db = FixtureDatabase::new();
2567
2568 let conftest_content = r#"
2570import pytest
2571
2572@pytest.fixture
2573def foo():
2574 return 42
2575"#;
2576 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2577 db.analyze_file(conftest_path.clone(), conftest_content);
2578
2579 let test_content = r#"
2581def test_xxx(
2582 foo,
2583):
2584 assert foo == 42
2585"#;
2586 let test_path = PathBuf::from("/tmp/test/test_example.py");
2587 db.analyze_file(test_path.clone(), test_content);
2588
2589 if let Some(usages) = db.usages.get(&test_path) {
2595 println!("Usages recorded:");
2596 for usage in usages.iter() {
2597 println!(" {} at line {} (1-indexed)", usage.name, usage.line);
2598 }
2599 } else {
2600 println!("No usages recorded for test file");
2601 }
2602
2603 let result = db.find_fixture_definition(&test_path, 2, 4);
2612
2613 assert!(
2614 result.is_some(),
2615 "Should find fixture definition when cursor is on parameter line"
2616 );
2617 let def = result.unwrap();
2618 assert_eq!(def.name, "foo");
2619 }
2620
2621 #[test]
2622 fn test_find_references_from_usage() {
2623 let db = FixtureDatabase::new();
2624
2625 let test_content = r#"
2627import pytest
2628
2629@pytest.fixture
2630def foo(): ...
2631
2632
2633def test_xxx(foo):
2634 pass
2635"#;
2636 let test_path = PathBuf::from("/tmp/test/test_example.py");
2637 db.analyze_file(test_path.clone(), test_content);
2638
2639 let foo_defs = db.definitions.get("foo").unwrap();
2641 assert_eq!(foo_defs.len(), 1, "Should have exactly one foo definition");
2642 let foo_def = &foo_defs[0];
2643 assert_eq!(foo_def.line, 5, "foo definition should be on line 5");
2644
2645 let refs_from_def = db.find_references_for_definition(foo_def);
2647 println!("References from definition:");
2648 for r in &refs_from_def {
2649 println!(" {} at line {}", r.name, r.line);
2650 }
2651
2652 assert_eq!(
2653 refs_from_def.len(),
2654 1,
2655 "Should find 1 usage reference (test_xxx parameter)"
2656 );
2657 assert_eq!(refs_from_def[0].line, 8, "Usage should be on line 8");
2658
2659 let fixture_name = db.find_fixture_at_position(&test_path, 7, 13);
2662 println!(
2663 "\nfind_fixture_at_position(line 7, char 13): {:?}",
2664 fixture_name
2665 );
2666
2667 assert_eq!(
2668 fixture_name,
2669 Some("foo".to_string()),
2670 "Should find fixture name at usage position"
2671 );
2672
2673 let resolved_def = db.find_fixture_definition(&test_path, 7, 13);
2674 println!(
2675 "\nfind_fixture_definition(line 7, char 13): {:?}",
2676 resolved_def.as_ref().map(|d| (d.line, &d.file_path))
2677 );
2678
2679 assert!(resolved_def.is_some(), "Should resolve usage to definition");
2680 assert_eq!(
2681 resolved_def.unwrap(),
2682 *foo_def,
2683 "Should resolve to the correct definition"
2684 );
2685 }
2686
2687 #[test]
2688 fn test_find_references_with_ellipsis_body() {
2689 let db = FixtureDatabase::new();
2691
2692 let test_content = r#"@pytest.fixture
2693def foo(): ...
2694
2695
2696def test_xxx(foo):
2697 pass
2698"#;
2699 let test_path = PathBuf::from("/tmp/test/test_codegen.py");
2700 db.analyze_file(test_path.clone(), test_content);
2701
2702 let foo_defs = db.definitions.get("foo");
2704 println!(
2705 "foo definitions: {:?}",
2706 foo_defs
2707 .as_ref()
2708 .map(|defs| defs.iter().map(|d| d.line).collect::<Vec<_>>())
2709 );
2710
2711 if let Some(usages) = db.usages.get(&test_path) {
2713 println!("usages:");
2714 for u in usages.iter() {
2715 println!(" {} at line {}", u.name, u.line);
2716 }
2717 }
2718
2719 assert!(foo_defs.is_some(), "Should find foo definition");
2720 let foo_def = &foo_defs.unwrap()[0];
2721
2722 let usages = db.usages.get(&test_path).unwrap();
2724 let foo_usage = usages.iter().find(|u| u.name == "foo").unwrap();
2725
2726 let usage_lsp_line = (foo_usage.line - 1) as u32;
2728 println!("\nTesting from usage at LSP line {}", usage_lsp_line);
2729
2730 let fixture_name = db.find_fixture_at_position(&test_path, usage_lsp_line, 13);
2731 assert_eq!(
2732 fixture_name,
2733 Some("foo".to_string()),
2734 "Should find foo at usage"
2735 );
2736
2737 let def_from_usage = db.find_fixture_definition(&test_path, usage_lsp_line, 13);
2738 assert!(
2739 def_from_usage.is_some(),
2740 "Should resolve usage to definition"
2741 );
2742 assert_eq!(def_from_usage.unwrap(), *foo_def);
2743 }
2744
2745 #[test]
2746 fn test_fixture_hierarchy_parent_references() {
2747 let db = FixtureDatabase::new();
2750
2751 let parent_content = r#"
2753import pytest
2754
2755@pytest.fixture
2756def cli_runner():
2757 """Parent fixture"""
2758 return "parent"
2759"#;
2760 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2761 db.analyze_file(parent_conftest.clone(), parent_content);
2762
2763 let child_content = r#"
2765import pytest
2766
2767@pytest.fixture
2768def cli_runner(cli_runner):
2769 """Child override that uses parent"""
2770 return cli_runner
2771"#;
2772 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2773 db.analyze_file(child_conftest.clone(), child_content);
2774
2775 let test_content = r#"
2777def test_one(cli_runner):
2778 pass
2779
2780def test_two(cli_runner):
2781 pass
2782"#;
2783 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2784 db.analyze_file(test_path.clone(), test_content);
2785
2786 let parent_defs = db.definitions.get("cli_runner").unwrap();
2788 let parent_def = parent_defs
2789 .iter()
2790 .find(|d| d.file_path == parent_conftest)
2791 .unwrap();
2792
2793 println!(
2794 "\nParent definition: {:?}:{}",
2795 parent_def.file_path, parent_def.line
2796 );
2797
2798 let refs = db.find_references_for_definition(parent_def);
2800
2801 println!("\nReferences for parent definition:");
2802 for r in &refs {
2803 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2804 }
2805
2806 assert!(
2813 refs.len() <= 2,
2814 "Parent should have at most 2 references: child definition and its parameter, got {}",
2815 refs.len()
2816 );
2817
2818 let child_refs: Vec<_> = refs
2820 .iter()
2821 .filter(|r| r.file_path == child_conftest)
2822 .collect();
2823 assert!(
2824 !child_refs.is_empty(),
2825 "Parent references should include child fixture definition"
2826 );
2827
2828 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2830 assert!(
2831 test_refs.is_empty(),
2832 "Parent references should NOT include child's test file usages"
2833 );
2834 }
2835
2836 #[test]
2837 fn test_fixture_hierarchy_child_references() {
2838 let db = FixtureDatabase::new();
2841
2842 let parent_content = r#"
2844import pytest
2845
2846@pytest.fixture
2847def cli_runner():
2848 return "parent"
2849"#;
2850 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2851 db.analyze_file(parent_conftest.clone(), parent_content);
2852
2853 let child_content = r#"
2855import pytest
2856
2857@pytest.fixture
2858def cli_runner(cli_runner):
2859 return cli_runner
2860"#;
2861 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2862 db.analyze_file(child_conftest.clone(), child_content);
2863
2864 let test_content = r#"
2866def test_one(cli_runner):
2867 pass
2868
2869def test_two(cli_runner):
2870 pass
2871"#;
2872 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2873 db.analyze_file(test_path.clone(), test_content);
2874
2875 let child_defs = db.definitions.get("cli_runner").unwrap();
2877 let child_def = child_defs
2878 .iter()
2879 .find(|d| d.file_path == child_conftest)
2880 .unwrap();
2881
2882 println!(
2883 "\nChild definition: {:?}:{}",
2884 child_def.file_path, child_def.line
2885 );
2886
2887 let refs = db.find_references_for_definition(child_def);
2889
2890 println!("\nReferences for child definition:");
2891 for r in &refs {
2892 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2893 }
2894
2895 assert!(
2897 refs.len() >= 2,
2898 "Child should have at least 2 references from test file, got {}",
2899 refs.len()
2900 );
2901
2902 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2903 assert_eq!(
2904 test_refs.len(),
2905 2,
2906 "Should have 2 references from test file"
2907 );
2908 }
2909
2910 #[test]
2911 fn test_fixture_hierarchy_child_parameter_references() {
2912 let db = FixtureDatabase::new();
2915
2916 let parent_content = r#"
2918import pytest
2919
2920@pytest.fixture
2921def cli_runner():
2922 return "parent"
2923"#;
2924 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2925 db.analyze_file(parent_conftest.clone(), parent_content);
2926
2927 let child_content = r#"
2929import pytest
2930
2931@pytest.fixture
2932def cli_runner(cli_runner):
2933 return cli_runner
2934"#;
2935 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2936 db.analyze_file(child_conftest.clone(), child_content);
2937
2938 let resolved_def = db.find_fixture_definition(&child_conftest, 4, 15);
2942
2943 assert!(
2944 resolved_def.is_some(),
2945 "Child parameter should resolve to parent definition"
2946 );
2947
2948 let def = resolved_def.unwrap();
2949 assert_eq!(
2950 def.file_path, parent_conftest,
2951 "Should resolve to parent conftest"
2952 );
2953
2954 let refs = db.find_references_for_definition(&def);
2956
2957 println!("\nReferences for parent (from child parameter):");
2958 for r in &refs {
2959 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2960 }
2961
2962 let child_refs: Vec<_> = refs
2964 .iter()
2965 .filter(|r| r.file_path == child_conftest)
2966 .collect();
2967 assert!(
2968 !child_refs.is_empty(),
2969 "Parent references should include child fixture parameter"
2970 );
2971 }
2972
2973 #[test]
2974 fn test_fixture_hierarchy_usage_from_test() {
2975 let db = FixtureDatabase::new();
2978
2979 let parent_content = r#"
2981import pytest
2982
2983@pytest.fixture
2984def cli_runner():
2985 return "parent"
2986"#;
2987 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2988 db.analyze_file(parent_conftest.clone(), parent_content);
2989
2990 let child_content = r#"
2992import pytest
2993
2994@pytest.fixture
2995def cli_runner(cli_runner):
2996 return cli_runner
2997"#;
2998 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2999 db.analyze_file(child_conftest.clone(), child_content);
3000
3001 let test_content = r#"
3003def test_one(cli_runner):
3004 pass
3005
3006def test_two(cli_runner):
3007 pass
3008
3009def test_three(cli_runner):
3010 pass
3011"#;
3012 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
3013 db.analyze_file(test_path.clone(), test_content);
3014
3015 let resolved_def = db.find_fixture_definition(&test_path, 1, 13);
3017
3018 assert!(
3019 resolved_def.is_some(),
3020 "Usage should resolve to child definition"
3021 );
3022
3023 let def = resolved_def.unwrap();
3024 assert_eq!(
3025 def.file_path, child_conftest,
3026 "Should resolve to child conftest (not parent)"
3027 );
3028
3029 let refs = db.find_references_for_definition(&def);
3031
3032 println!("\nReferences for child (from test usage):");
3033 for r in &refs {
3034 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
3035 }
3036
3037 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
3039 assert_eq!(test_refs.len(), 3, "Should find all 3 usages in test file");
3040 }
3041
3042 #[test]
3043 fn test_fixture_hierarchy_multiple_levels() {
3044 let db = FixtureDatabase::new();
3046
3047 let grandparent_content = r#"
3049import pytest
3050
3051@pytest.fixture
3052def db():
3053 return "grandparent_db"
3054"#;
3055 let grandparent_conftest = PathBuf::from("/tmp/project/conftest.py");
3056 db.analyze_file(grandparent_conftest.clone(), grandparent_content);
3057
3058 let parent_content = r#"
3060import pytest
3061
3062@pytest.fixture
3063def db(db):
3064 return f"parent_{db}"
3065"#;
3066 let parent_conftest = PathBuf::from("/tmp/project/api/conftest.py");
3067 db.analyze_file(parent_conftest.clone(), parent_content);
3068
3069 let child_content = r#"
3071import pytest
3072
3073@pytest.fixture
3074def db(db):
3075 return f"child_{db}"
3076"#;
3077 let child_conftest = PathBuf::from("/tmp/project/api/tests/conftest.py");
3078 db.analyze_file(child_conftest.clone(), child_content);
3079
3080 let test_content = r#"
3082def test_db(db):
3083 pass
3084"#;
3085 let test_path = PathBuf::from("/tmp/project/api/tests/test_example.py");
3086 db.analyze_file(test_path.clone(), test_content);
3087
3088 let all_defs = db.definitions.get("db").unwrap();
3090 assert_eq!(all_defs.len(), 3, "Should have 3 definitions");
3091
3092 let grandparent_def = all_defs
3093 .iter()
3094 .find(|d| d.file_path == grandparent_conftest)
3095 .unwrap();
3096 let parent_def = all_defs
3097 .iter()
3098 .find(|d| d.file_path == parent_conftest)
3099 .unwrap();
3100 let child_def = all_defs
3101 .iter()
3102 .find(|d| d.file_path == child_conftest)
3103 .unwrap();
3104
3105 let resolved = db.find_fixture_definition(&test_path, 1, 12);
3107 assert_eq!(
3108 resolved.as_ref(),
3109 Some(child_def),
3110 "Test should use child definition"
3111 );
3112
3113 let child_refs = db.find_references_for_definition(child_def);
3115 let test_refs: Vec<_> = child_refs
3116 .iter()
3117 .filter(|r| r.file_path == test_path)
3118 .collect();
3119 assert!(
3120 !test_refs.is_empty(),
3121 "Child should have test file references"
3122 );
3123
3124 let parent_refs = db.find_references_for_definition(parent_def);
3126 let child_param_refs: Vec<_> = parent_refs
3127 .iter()
3128 .filter(|r| r.file_path == child_conftest)
3129 .collect();
3130 let test_refs_in_parent: Vec<_> = parent_refs
3131 .iter()
3132 .filter(|r| r.file_path == test_path)
3133 .collect();
3134
3135 assert!(
3136 !child_param_refs.is_empty(),
3137 "Parent should have child parameter reference"
3138 );
3139 assert!(
3140 test_refs_in_parent.is_empty(),
3141 "Parent should NOT have test file references"
3142 );
3143
3144 let grandparent_refs = db.find_references_for_definition(grandparent_def);
3146 let parent_param_refs: Vec<_> = grandparent_refs
3147 .iter()
3148 .filter(|r| r.file_path == parent_conftest)
3149 .collect();
3150 let child_refs_in_gp: Vec<_> = grandparent_refs
3151 .iter()
3152 .filter(|r| r.file_path == child_conftest)
3153 .collect();
3154
3155 assert!(
3156 !parent_param_refs.is_empty(),
3157 "Grandparent should have parent parameter reference"
3158 );
3159 assert!(
3160 child_refs_in_gp.is_empty(),
3161 "Grandparent should NOT have child references"
3162 );
3163 }
3164
3165 #[test]
3166 fn test_fixture_hierarchy_same_file_override() {
3167 let db = FixtureDatabase::new();
3170
3171 let content = r#"
3172import pytest
3173
3174@pytest.fixture
3175def base():
3176 return "base"
3177
3178@pytest.fixture
3179def base(base):
3180 return f"override_{base}"
3181
3182def test_uses_override(base):
3183 pass
3184"#;
3185 let test_path = PathBuf::from("/tmp/test/test_example.py");
3186 db.analyze_file(test_path.clone(), content);
3187
3188 let defs = db.definitions.get("base").unwrap();
3189 assert_eq!(defs.len(), 2, "Should have 2 definitions in same file");
3190
3191 println!("\nDefinitions found:");
3192 for d in defs.iter() {
3193 println!(" base at line {}", d.line);
3194 }
3195
3196 if let Some(usages) = db.usages.get(&test_path) {
3198 println!("\nUsages found:");
3199 for u in usages.iter() {
3200 println!(" {} at line {}", u.name, u.line);
3201 }
3202 } else {
3203 println!("\nNo usages found!");
3204 }
3205
3206 let resolved = db.find_fixture_definition(&test_path, 11, 23);
3210
3211 println!("\nResolved: {:?}", resolved.as_ref().map(|d| d.line));
3212
3213 assert!(resolved.is_some(), "Should resolve to override definition");
3214
3215 let override_def = defs.iter().find(|d| d.line == 9).unwrap();
3217 println!("Override def at line: {}", override_def.line);
3218 assert_eq!(resolved.as_ref(), Some(override_def));
3219 }
3220
3221 #[test]
3222 fn test_cursor_position_on_definition_line() {
3223 let db = FixtureDatabase::new();
3226
3227 let parent_content = r#"
3229import pytest
3230
3231@pytest.fixture
3232def cli_runner():
3233 return "parent"
3234"#;
3235 let parent_conftest = PathBuf::from("/tmp/conftest.py");
3236 db.analyze_file(parent_conftest.clone(), parent_content);
3237
3238 let content = r#"
3239import pytest
3240
3241@pytest.fixture
3242def cli_runner(cli_runner):
3243 return cli_runner
3244"#;
3245 let test_path = PathBuf::from("/tmp/test/test_example.py");
3246 db.analyze_file(test_path.clone(), content);
3247
3248 println!("\n=== Testing character positions on line 5 ===");
3255
3256 if let Some(usages) = db.usages.get(&test_path) {
3258 println!("\nUsages found:");
3259 for u in usages.iter() {
3260 println!(
3261 " {} at line {}, chars {}-{}",
3262 u.name, u.line, u.start_char, u.end_char
3263 );
3264 }
3265 } else {
3266 println!("\nNo usages found!");
3267 }
3268
3269 let line_content = "def cli_runner(cli_runner):";
3271 println!("\nLine content: '{}'", line_content);
3272
3273 println!("\nPosition 4 (function name):");
3275 let word_at_4 = db.extract_word_at_position(line_content, 4);
3276 println!(" Word at cursor: {:?}", word_at_4);
3277 let fixture_name_at_4 = db.find_fixture_at_position(&test_path, 4, 4);
3278 println!(" find_fixture_at_position: {:?}", fixture_name_at_4);
3279 let resolved_4 = db.find_fixture_definition(&test_path, 4, 4); println!(
3281 " Resolved: {:?}",
3282 resolved_4.as_ref().map(|d| (d.name.as_str(), d.line))
3283 );
3284
3285 println!("\nPosition 16 (parameter name):");
3287 let word_at_16 = db.extract_word_at_position(line_content, 16);
3288 println!(" Word at cursor: {:?}", word_at_16);
3289
3290 if let Some(usages) = db.usages.get(&test_path) {
3292 for usage in usages.iter() {
3293 println!(" Checking usage: {} at line {}", usage.name, usage.line);
3294 if usage.line == 5 && usage.name == "cli_runner" {
3295 println!(" MATCH! Usage matches our position");
3296 }
3297 }
3298 }
3299
3300 let fixture_name_at_16 = db.find_fixture_at_position(&test_path, 4, 16);
3301 println!(" find_fixture_at_position: {:?}", fixture_name_at_16);
3302 let resolved_16 = db.find_fixture_definition(&test_path, 4, 16); println!(
3304 " Resolved: {:?}",
3305 resolved_16.as_ref().map(|d| (d.name.as_str(), d.line))
3306 );
3307
3308 assert_eq!(word_at_4, Some("cli_runner".to_string()));
3313 assert_eq!(word_at_16, Some("cli_runner".to_string()));
3314
3315 println!("\n=== ACTUAL vs EXPECTED ===");
3317 println!("Position 4 (function name):");
3318 println!(
3319 " Actual: {:?}",
3320 resolved_4.as_ref().map(|d| (&d.file_path, d.line))
3321 );
3322 println!(" Expected: test file, line 5 (the child definition itself)");
3323
3324 println!("\nPosition 16 (parameter):");
3325 println!(
3326 " Actual: {:?}",
3327 resolved_16.as_ref().map(|d| (&d.file_path, d.line))
3328 );
3329 println!(" Expected: conftest, line 5 (the parent definition)");
3330
3331 if let Some(ref def) = resolved_16 {
3337 assert_eq!(
3338 def.file_path, parent_conftest,
3339 "Parameter should resolve to parent definition"
3340 );
3341 } else {
3342 panic!("Position 16 (parameter) should resolve to parent definition");
3343 }
3344 }
3345
3346 #[test]
3347 fn test_undeclared_fixture_detection_in_test() {
3348 let db = FixtureDatabase::new();
3349
3350 let conftest_content = r#"
3352import pytest
3353
3354@pytest.fixture
3355def my_fixture():
3356 return 42
3357"#;
3358 let conftest_path = PathBuf::from("/tmp/conftest.py");
3359 db.analyze_file(conftest_path.clone(), conftest_content);
3360
3361 let test_content = r#"
3363def test_example():
3364 result = my_fixture.get()
3365 assert result == 42
3366"#;
3367 let test_path = PathBuf::from("/tmp/test_example.py");
3368 db.analyze_file(test_path.clone(), test_content);
3369
3370 let undeclared = db.get_undeclared_fixtures(&test_path);
3372 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3373
3374 let fixture = &undeclared[0];
3375 assert_eq!(fixture.name, "my_fixture");
3376 assert_eq!(fixture.function_name, "test_example");
3377 assert_eq!(fixture.line, 3); }
3379
3380 #[test]
3381 fn test_undeclared_fixture_detection_in_fixture() {
3382 let db = FixtureDatabase::new();
3383
3384 let conftest_content = r#"
3386import pytest
3387
3388@pytest.fixture
3389def base_fixture():
3390 return "base"
3391
3392@pytest.fixture
3393def helper_fixture():
3394 return "helper"
3395"#;
3396 let conftest_path = PathBuf::from("/tmp/conftest.py");
3397 db.analyze_file(conftest_path.clone(), conftest_content);
3398
3399 let test_content = r#"
3401import pytest
3402
3403@pytest.fixture
3404def my_fixture(base_fixture):
3405 data = helper_fixture.value
3406 return f"{base_fixture}-{data}"
3407"#;
3408 let test_path = PathBuf::from("/tmp/test_example.py");
3409 db.analyze_file(test_path.clone(), test_content);
3410
3411 let undeclared = db.get_undeclared_fixtures(&test_path);
3413 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3414
3415 let fixture = &undeclared[0];
3416 assert_eq!(fixture.name, "helper_fixture");
3417 assert_eq!(fixture.function_name, "my_fixture");
3418 assert_eq!(fixture.line, 6); }
3420
3421 #[test]
3422 fn test_no_false_positive_for_declared_fixtures() {
3423 let db = FixtureDatabase::new();
3424
3425 let conftest_content = r#"
3427import pytest
3428
3429@pytest.fixture
3430def my_fixture():
3431 return 42
3432"#;
3433 let conftest_path = PathBuf::from("/tmp/conftest.py");
3434 db.analyze_file(conftest_path.clone(), conftest_content);
3435
3436 let test_content = r#"
3438def test_example(my_fixture):
3439 result = my_fixture
3440 assert result == 42
3441"#;
3442 let test_path = PathBuf::from("/tmp/test_example.py");
3443 db.analyze_file(test_path.clone(), test_content);
3444
3445 let undeclared = db.get_undeclared_fixtures(&test_path);
3447 assert_eq!(
3448 undeclared.len(),
3449 0,
3450 "Should not detect any undeclared fixtures"
3451 );
3452 }
3453
3454 #[test]
3455 fn test_no_false_positive_for_non_fixtures() {
3456 let db = FixtureDatabase::new();
3457
3458 let test_content = r#"
3460def test_example():
3461 my_variable = 42
3462 result = my_variable + 10
3463 assert result == 52
3464"#;
3465 let test_path = PathBuf::from("/tmp/test_example.py");
3466 db.analyze_file(test_path.clone(), test_content);
3467
3468 let undeclared = db.get_undeclared_fixtures(&test_path);
3470 assert_eq!(
3471 undeclared.len(),
3472 0,
3473 "Should not detect any undeclared fixtures"
3474 );
3475 }
3476
3477 #[test]
3478 fn test_undeclared_fixture_not_available_in_hierarchy() {
3479 let db = FixtureDatabase::new();
3480
3481 let other_conftest = r#"
3483import pytest
3484
3485@pytest.fixture
3486def other_fixture():
3487 return "other"
3488"#;
3489 let other_path = PathBuf::from("/other/conftest.py");
3490 db.analyze_file(other_path.clone(), other_conftest);
3491
3492 let test_content = r#"
3494def test_example():
3495 result = other_fixture.value
3496 assert result == "other"
3497"#;
3498 let test_path = PathBuf::from("/tmp/test_example.py");
3499 db.analyze_file(test_path.clone(), test_content);
3500
3501 let undeclared = db.get_undeclared_fixtures(&test_path);
3503 assert_eq!(
3504 undeclared.len(),
3505 0,
3506 "Should not detect fixtures not in hierarchy"
3507 );
3508 }
3509}
3510
3511#[test]
3512fn test_undeclared_fixture_in_async_test() {
3513 let db = FixtureDatabase::new();
3514
3515 let content = r#"
3517import pytest
3518
3519@pytest.fixture
3520def http_client():
3521 return "MockClient"
3522
3523async def test_with_undeclared():
3524 response = await http_client.query("test")
3525 assert response == "test"
3526"#;
3527 let test_path = PathBuf::from("/tmp/test_example.py");
3528 db.analyze_file(test_path.clone(), content);
3529
3530 let undeclared = db.get_undeclared_fixtures(&test_path);
3532
3533 println!("Found {} undeclared fixtures", undeclared.len());
3534 for u in &undeclared {
3535 println!(" - {} at line {} in {}", u.name, u.line, u.function_name);
3536 }
3537
3538 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3539 assert_eq!(undeclared[0].name, "http_client");
3540 assert_eq!(undeclared[0].function_name, "test_with_undeclared");
3541 assert_eq!(undeclared[0].line, 9);
3542}
3543
3544#[test]
3545fn test_undeclared_fixture_in_assert_statement() {
3546 let db = FixtureDatabase::new();
3547
3548 let conftest_content = r#"
3550import pytest
3551
3552@pytest.fixture
3553def expected_value():
3554 return 42
3555"#;
3556 let conftest_path = PathBuf::from("/tmp/conftest.py");
3557 db.analyze_file(conftest_path.clone(), conftest_content);
3558
3559 let test_content = r#"
3561def test_assertion():
3562 result = calculate_value()
3563 assert result == expected_value
3564"#;
3565 let test_path = PathBuf::from("/tmp/test_example.py");
3566 db.analyze_file(test_path.clone(), test_content);
3567
3568 let undeclared = db.get_undeclared_fixtures(&test_path);
3570
3571 assert_eq!(
3572 undeclared.len(),
3573 1,
3574 "Should detect one undeclared fixture in assert"
3575 );
3576 assert_eq!(undeclared[0].name, "expected_value");
3577 assert_eq!(undeclared[0].function_name, "test_assertion");
3578}
3579
3580#[test]
3581fn test_no_false_positive_for_local_variable() {
3582 let db = FixtureDatabase::new();
3584
3585 let conftest_content = r#"
3587import pytest
3588
3589@pytest.fixture
3590def foo():
3591 return "fixture"
3592"#;
3593 let conftest_path = PathBuf::from("/tmp/conftest.py");
3594 db.analyze_file(conftest_path.clone(), conftest_content);
3595
3596 let test_content = r#"
3598def test_with_local_variable():
3599 foo = "local variable"
3600 result = foo.upper()
3601 assert result == "LOCAL VARIABLE"
3602"#;
3603 let test_path = PathBuf::from("/tmp/test_example.py");
3604 db.analyze_file(test_path.clone(), test_content);
3605
3606 let undeclared = db.get_undeclared_fixtures(&test_path);
3608
3609 assert_eq!(
3610 undeclared.len(),
3611 0,
3612 "Should not detect undeclared fixture when name is a local variable"
3613 );
3614}
3615
3616#[test]
3617fn test_no_false_positive_for_imported_name() {
3618 let db = FixtureDatabase::new();
3620
3621 let conftest_content = r#"
3623import pytest
3624
3625@pytest.fixture
3626def foo():
3627 return "fixture"
3628"#;
3629 let conftest_path = PathBuf::from("/tmp/conftest.py");
3630 db.analyze_file(conftest_path.clone(), conftest_content);
3631
3632 let test_content = r#"
3634from mymodule import foo
3635
3636def test_with_import():
3637 result = foo.something()
3638 assert result == "value"
3639"#;
3640 let test_path = PathBuf::from("/tmp/test_example.py");
3641 db.analyze_file(test_path.clone(), test_content);
3642
3643 let undeclared = db.get_undeclared_fixtures(&test_path);
3645
3646 assert_eq!(
3647 undeclared.len(),
3648 0,
3649 "Should not detect undeclared fixture when name is imported"
3650 );
3651}
3652
3653#[test]
3654fn test_warn_for_fixture_used_directly() {
3655 let db = FixtureDatabase::new();
3658
3659 let test_content = r#"
3660import pytest
3661
3662@pytest.fixture
3663def foo():
3664 return "fixture"
3665
3666def test_using_fixture_directly():
3667 # This is an error - fixtures must be declared as parameters
3668 result = foo.something()
3669 assert result == "value"
3670"#;
3671 let test_path = PathBuf::from("/tmp/test_example.py");
3672 db.analyze_file(test_path.clone(), test_content);
3673
3674 let undeclared = db.get_undeclared_fixtures(&test_path);
3676
3677 assert_eq!(
3678 undeclared.len(),
3679 1,
3680 "Should detect fixture used directly without parameter declaration"
3681 );
3682 assert_eq!(undeclared[0].name, "foo");
3683 assert_eq!(undeclared[0].function_name, "test_using_fixture_directly");
3684}
3685
3686#[test]
3687fn test_no_false_positive_for_module_level_assignment() {
3688 let db = FixtureDatabase::new();
3690
3691 let conftest_content = r#"
3693import pytest
3694
3695@pytest.fixture
3696def foo():
3697 return "fixture"
3698"#;
3699 let conftest_path = PathBuf::from("/tmp/conftest.py");
3700 db.analyze_file(conftest_path.clone(), conftest_content);
3701
3702 let test_content = r#"
3704# Module-level assignment
3705foo = SomeClass()
3706
3707def test_with_module_var():
3708 result = foo.method()
3709 assert result == "value"
3710"#;
3711 let test_path = PathBuf::from("/tmp/test_example.py");
3712 db.analyze_file(test_path.clone(), test_content);
3713
3714 let undeclared = db.get_undeclared_fixtures(&test_path);
3716
3717 assert_eq!(
3718 undeclared.len(),
3719 0,
3720 "Should not detect undeclared fixture when name is assigned at module level"
3721 );
3722}
3723
3724#[test]
3725fn test_no_false_positive_for_function_definition() {
3726 let db = FixtureDatabase::new();
3728
3729 let conftest_content = r#"
3731import pytest
3732
3733@pytest.fixture
3734def foo():
3735 return "fixture"
3736"#;
3737 let conftest_path = PathBuf::from("/tmp/conftest.py");
3738 db.analyze_file(conftest_path.clone(), conftest_content);
3739
3740 let test_content = r#"
3742def foo():
3743 return "not a fixture"
3744
3745def test_with_function():
3746 result = foo()
3747 assert result == "not a fixture"
3748"#;
3749 let test_path = PathBuf::from("/tmp/test_example.py");
3750 db.analyze_file(test_path.clone(), test_content);
3751
3752 let undeclared = db.get_undeclared_fixtures(&test_path);
3754
3755 assert_eq!(
3756 undeclared.len(),
3757 0,
3758 "Should not detect undeclared fixture when name is a regular function"
3759 );
3760}
3761
3762#[test]
3763fn test_no_false_positive_for_class_definition() {
3764 let db = FixtureDatabase::new();
3766
3767 let conftest_content = r#"
3769import pytest
3770
3771@pytest.fixture
3772def MyClass():
3773 return "fixture"
3774"#;
3775 let conftest_path = PathBuf::from("/tmp/conftest.py");
3776 db.analyze_file(conftest_path.clone(), conftest_content);
3777
3778 let test_content = r#"
3780class MyClass:
3781 pass
3782
3783def test_with_class():
3784 obj = MyClass()
3785 assert obj is not None
3786"#;
3787 let test_path = PathBuf::from("/tmp/test_example.py");
3788 db.analyze_file(test_path.clone(), test_content);
3789
3790 let undeclared = db.get_undeclared_fixtures(&test_path);
3792
3793 assert_eq!(
3794 undeclared.len(),
3795 0,
3796 "Should not detect undeclared fixture when name is a class"
3797 );
3798}
3799
3800#[test]
3801fn test_line_aware_local_variable_scope() {
3802 let db = FixtureDatabase::new();
3804
3805 let conftest_content = r#"
3807import pytest
3808
3809@pytest.fixture
3810def http_client():
3811 return "MockClient"
3812"#;
3813 let conftest_path = PathBuf::from("/tmp/conftest.py");
3814 db.analyze_file(conftest_path.clone(), conftest_content);
3815
3816 let test_content = r#"async def test_example():
3818 # Line 1: http_client should be flagged (not yet assigned)
3819 result = await http_client.get("/api")
3820 # Line 3: Now we assign http_client locally
3821 http_client = "local"
3822 # Line 5: http_client should NOT be flagged (local var now)
3823 result2 = await http_client.get("/api2")
3824"#;
3825 let test_path = PathBuf::from("/tmp/test_example.py");
3826 db.analyze_file(test_path.clone(), test_content);
3827
3828 let undeclared = db.get_undeclared_fixtures(&test_path);
3830
3831 assert_eq!(
3834 undeclared.len(),
3835 1,
3836 "Should detect http_client only before local assignment"
3837 );
3838 assert_eq!(undeclared[0].name, "http_client");
3839 assert_eq!(
3841 undeclared[0].line, 3,
3842 "Should flag usage on line 3 (before assignment on line 5)"
3843 );
3844}
3845
3846#[test]
3847fn test_same_line_assignment_and_usage() {
3848 let db = FixtureDatabase::new();
3850
3851 let conftest_content = r#"import pytest
3852
3853@pytest.fixture
3854def http_client():
3855 return "parent"
3856"#;
3857 let conftest_path = PathBuf::from("/tmp/conftest.py");
3858 db.analyze_file(conftest_path.clone(), conftest_content);
3859
3860 let test_content = r#"async def test_example():
3861 # This references the fixture on the RHS, then assigns to local var
3862 http_client = await http_client.get("/api")
3863"#;
3864 let test_path = PathBuf::from("/tmp/test_example.py");
3865 db.analyze_file(test_path.clone(), test_content);
3866
3867 let undeclared = db.get_undeclared_fixtures(&test_path);
3868
3869 assert_eq!(undeclared.len(), 1);
3871 assert_eq!(undeclared[0].name, "http_client");
3872 assert_eq!(undeclared[0].line, 3);
3873}
3874
3875#[test]
3876fn test_no_false_positive_for_later_assignment() {
3877 let db = FixtureDatabase::new();
3880
3881 let conftest_content = r#"import pytest
3882
3883@pytest.fixture
3884def http_client():
3885 return "fixture"
3886"#;
3887 let conftest_path = PathBuf::from("/tmp/conftest.py");
3888 db.analyze_file(conftest_path.clone(), conftest_content);
3889
3890 let test_content = r#"async def test_example():
3893 result = await http_client.get("/api") # Should be flagged
3894 # Now assign locally
3895 http_client = "local"
3896 # This should NOT be flagged because variable is now assigned
3897 result2 = http_client
3898"#;
3899 let test_path = PathBuf::from("/tmp/test_example.py");
3900 db.analyze_file(test_path.clone(), test_content);
3901
3902 let undeclared = db.get_undeclared_fixtures(&test_path);
3903
3904 assert_eq!(
3906 undeclared.len(),
3907 1,
3908 "Should detect exactly one undeclared fixture"
3909 );
3910 assert_eq!(undeclared[0].name, "http_client");
3911 assert_eq!(
3912 undeclared[0].line, 2,
3913 "Should flag usage on line 2 before assignment on line 4"
3914 );
3915}
3916
3917#[test]
3918fn test_fixture_resolution_priority_deterministic() {
3919 let db = FixtureDatabase::new();
3922
3923 let root_content = r#"
3928import pytest
3929
3930@pytest.fixture
3931def db():
3932 return "root_db"
3933"#;
3934 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
3935 db.analyze_file(root_conftest.clone(), root_content);
3936
3937 let unrelated_content = r#"
3939import pytest
3940
3941@pytest.fixture
3942def db():
3943 return "unrelated_db"
3944"#;
3945 let unrelated_conftest = PathBuf::from("/tmp/other/conftest.py");
3946 db.analyze_file(unrelated_conftest.clone(), unrelated_content);
3947
3948 let app_content = r#"
3950import pytest
3951
3952@pytest.fixture
3953def db():
3954 return "app_db"
3955"#;
3956 let app_conftest = PathBuf::from("/tmp/project/app/conftest.py");
3957 db.analyze_file(app_conftest.clone(), app_content);
3958
3959 let tests_content = r#"
3961import pytest
3962
3963@pytest.fixture
3964def db():
3965 return "tests_db"
3966"#;
3967 let tests_conftest = PathBuf::from("/tmp/project/app/tests/conftest.py");
3968 db.analyze_file(tests_conftest.clone(), tests_content);
3969
3970 let test_content = r#"
3972def test_database(db):
3973 assert db is not None
3974"#;
3975 let test_path = PathBuf::from("/tmp/project/app/tests/test_foo.py");
3976 db.analyze_file(test_path.clone(), test_content);
3977
3978 for iteration in 0..10 {
3980 let result = db.find_fixture_definition(&test_path, 1, 18); assert!(
3983 result.is_some(),
3984 "Iteration {}: Should find a fixture definition",
3985 iteration
3986 );
3987
3988 let def = result.unwrap();
3989 assert_eq!(
3990 def.name, "db",
3991 "Iteration {}: Should find 'db' fixture",
3992 iteration
3993 );
3994
3995 assert_eq!(
3997 def.file_path, tests_conftest,
3998 "Iteration {}: Should consistently resolve to closest conftest.py at {:?}, but got {:?}",
3999 iteration,
4000 tests_conftest,
4001 def.file_path
4002 );
4003 }
4004}
4005
4006#[test]
4007fn test_fixture_resolution_prefers_parent_over_unrelated() {
4008 let db = FixtureDatabase::new();
4011
4012 let unrelated_content = r#"
4014import pytest
4015
4016@pytest.fixture
4017def custom_fixture():
4018 return "unrelated"
4019"#;
4020 let unrelated_conftest = PathBuf::from("/tmp/other_project/conftest.py");
4021 db.analyze_file(unrelated_conftest.clone(), unrelated_content);
4022
4023 let third_party_content = r#"
4025import pytest
4026
4027@pytest.fixture
4028def custom_fixture():
4029 return "third_party"
4030"#;
4031 let third_party_path =
4032 PathBuf::from("/tmp/.venv/lib/python3.11/site-packages/pytest_custom/plugin.py");
4033 db.analyze_file(third_party_path.clone(), third_party_content);
4034
4035 let test_content = r#"
4037def test_custom(custom_fixture):
4038 assert custom_fixture is not None
4039"#;
4040 let test_path = PathBuf::from("/tmp/my_project/test_foo.py");
4041 db.analyze_file(test_path.clone(), test_content);
4042
4043 let result = db.find_fixture_definition(&test_path, 1, 16);
4045 assert!(result.is_some());
4046 let def = result.unwrap();
4047
4048 assert_eq!(
4050 def.file_path, third_party_path,
4051 "Should prefer third-party fixture from site-packages over unrelated conftest.py"
4052 );
4053}
4054
4055#[test]
4056fn test_fixture_resolution_hierarchy_over_third_party() {
4057 let db = FixtureDatabase::new();
4059
4060 let third_party_content = r#"
4062import pytest
4063
4064@pytest.fixture
4065def mocker():
4066 return "third_party_mocker"
4067"#;
4068 let third_party_path =
4069 PathBuf::from("/tmp/project/.venv/lib/python3.11/site-packages/pytest_mock/plugin.py");
4070 db.analyze_file(third_party_path.clone(), third_party_content);
4071
4072 let local_content = r#"
4074import pytest
4075
4076@pytest.fixture
4077def mocker():
4078 return "local_mocker"
4079"#;
4080 let local_conftest = PathBuf::from("/tmp/project/conftest.py");
4081 db.analyze_file(local_conftest.clone(), local_content);
4082
4083 let test_content = r#"
4085def test_mocking(mocker):
4086 assert mocker is not None
4087"#;
4088 let test_path = PathBuf::from("/tmp/project/test_foo.py");
4089 db.analyze_file(test_path.clone(), test_content);
4090
4091 let result = db.find_fixture_definition(&test_path, 1, 17);
4093 assert!(result.is_some());
4094 let def = result.unwrap();
4095
4096 assert_eq!(
4097 def.file_path, local_conftest,
4098 "Should prefer local conftest.py fixture over third-party fixture"
4099 );
4100}
4101
4102#[test]
4103fn test_fixture_resolution_with_relative_paths() {
4104 let db = FixtureDatabase::new();
4107
4108 let conftest_content = r#"
4110import pytest
4111
4112@pytest.fixture
4113def shared():
4114 return "conftest"
4115"#;
4116 let conftest_abs = PathBuf::from("/tmp/project/tests/conftest.py");
4117 db.analyze_file(conftest_abs.clone(), conftest_content);
4118
4119 let test_content = r#"
4121def test_example(shared):
4122 assert shared == "conftest"
4123"#;
4124 let test_abs = PathBuf::from("/tmp/project/tests/test_foo.py");
4125 db.analyze_file(test_abs.clone(), test_content);
4126
4127 let result = db.find_fixture_definition(&test_abs, 1, 17);
4129 assert!(result.is_some(), "Should find fixture with absolute paths");
4130 let def = result.unwrap();
4131 assert_eq!(def.file_path, conftest_abs, "Should resolve to conftest.py");
4132}
4133
4134#[test]
4135fn test_fixture_resolution_deep_hierarchy() {
4136 let db = FixtureDatabase::new();
4138
4139 let root_content = r#"
4141import pytest
4142
4143@pytest.fixture
4144def db():
4145 return "root"
4146"#;
4147 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4148 db.analyze_file(root_conftest.clone(), root_content);
4149
4150 let level1_content = r#"
4152import pytest
4153
4154@pytest.fixture
4155def db():
4156 return "level1"
4157"#;
4158 let level1_conftest = PathBuf::from("/tmp/project/src/conftest.py");
4159 db.analyze_file(level1_conftest.clone(), level1_content);
4160
4161 let level2_content = r#"
4163import pytest
4164
4165@pytest.fixture
4166def db():
4167 return "level2"
4168"#;
4169 let level2_conftest = PathBuf::from("/tmp/project/src/app/conftest.py");
4170 db.analyze_file(level2_conftest.clone(), level2_content);
4171
4172 let level3_content = r#"
4174import pytest
4175
4176@pytest.fixture
4177def db():
4178 return "level3"
4179"#;
4180 let level3_conftest = PathBuf::from("/tmp/project/src/app/tests/conftest.py");
4181 db.analyze_file(level3_conftest.clone(), level3_content);
4182
4183 let test_l3_content = r#"
4185def test_db(db):
4186 assert db == "level3"
4187"#;
4188 let test_l3 = PathBuf::from("/tmp/project/src/app/tests/test_foo.py");
4189 db.analyze_file(test_l3.clone(), test_l3_content);
4190
4191 let result_l3 = db.find_fixture_definition(&test_l3, 1, 12);
4192 assert!(result_l3.is_some());
4193 assert_eq!(
4194 result_l3.unwrap().file_path,
4195 level3_conftest,
4196 "Test at level 3 should use level 3 fixture"
4197 );
4198
4199 let test_l2_content = r#"
4201def test_db(db):
4202 assert db == "level2"
4203"#;
4204 let test_l2 = PathBuf::from("/tmp/project/src/app/test_bar.py");
4205 db.analyze_file(test_l2.clone(), test_l2_content);
4206
4207 let result_l2 = db.find_fixture_definition(&test_l2, 1, 12);
4208 assert!(result_l2.is_some());
4209 assert_eq!(
4210 result_l2.unwrap().file_path,
4211 level2_conftest,
4212 "Test at level 2 should use level 2 fixture"
4213 );
4214
4215 let test_l1_content = r#"
4217def test_db(db):
4218 assert db == "level1"
4219"#;
4220 let test_l1 = PathBuf::from("/tmp/project/src/test_baz.py");
4221 db.analyze_file(test_l1.clone(), test_l1_content);
4222
4223 let result_l1 = db.find_fixture_definition(&test_l1, 1, 12);
4224 assert!(result_l1.is_some());
4225 assert_eq!(
4226 result_l1.unwrap().file_path,
4227 level1_conftest,
4228 "Test at level 1 should use level 1 fixture"
4229 );
4230
4231 let test_root_content = r#"
4233def test_db(db):
4234 assert db == "root"
4235"#;
4236 let test_root = PathBuf::from("/tmp/project/test_root.py");
4237 db.analyze_file(test_root.clone(), test_root_content);
4238
4239 let result_root = db.find_fixture_definition(&test_root, 1, 12);
4240 assert!(result_root.is_some());
4241 assert_eq!(
4242 result_root.unwrap().file_path,
4243 root_conftest,
4244 "Test at root should use root fixture"
4245 );
4246}
4247
4248#[test]
4249fn test_fixture_resolution_sibling_directories() {
4250 let db = FixtureDatabase::new();
4252
4253 let root_content = r#"
4255import pytest
4256
4257@pytest.fixture
4258def shared():
4259 return "root"
4260"#;
4261 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4262 db.analyze_file(root_conftest.clone(), root_content);
4263
4264 let module_a_content = r#"
4266import pytest
4267
4268@pytest.fixture
4269def module_specific():
4270 return "module_a"
4271"#;
4272 let module_a_conftest = PathBuf::from("/tmp/project/module_a/conftest.py");
4273 db.analyze_file(module_a_conftest.clone(), module_a_content);
4274
4275 let module_b_content = r#"
4277import pytest
4278
4279@pytest.fixture
4280def module_specific():
4281 return "module_b"
4282"#;
4283 let module_b_conftest = PathBuf::from("/tmp/project/module_b/conftest.py");
4284 db.analyze_file(module_b_conftest.clone(), module_b_content);
4285
4286 let test_a_content = r#"
4288def test_a(module_specific, shared):
4289 assert module_specific == "module_a"
4290 assert shared == "root"
4291"#;
4292 let test_a = PathBuf::from("/tmp/project/module_a/test_a.py");
4293 db.analyze_file(test_a.clone(), test_a_content);
4294
4295 let result_a = db.find_fixture_definition(&test_a, 1, 11);
4296 assert!(result_a.is_some());
4297 assert_eq!(
4298 result_a.unwrap().file_path,
4299 module_a_conftest,
4300 "Test in module_a should use module_a's fixture"
4301 );
4302
4303 let test_b_content = r#"
4305def test_b(module_specific, shared):
4306 assert module_specific == "module_b"
4307 assert shared == "root"
4308"#;
4309 let test_b = PathBuf::from("/tmp/project/module_b/test_b.py");
4310 db.analyze_file(test_b.clone(), test_b_content);
4311
4312 let result_b = db.find_fixture_definition(&test_b, 1, 11);
4313 assert!(result_b.is_some());
4314 assert_eq!(
4315 result_b.unwrap().file_path,
4316 module_b_conftest,
4317 "Test in module_b should use module_b's fixture"
4318 );
4319
4320 let result_a_shared = db.find_fixture_definition(&test_a, 1, 29);
4323 assert!(result_a_shared.is_some());
4324 assert_eq!(
4325 result_a_shared.unwrap().file_path,
4326 root_conftest,
4327 "Test in module_a should access root's shared fixture"
4328 );
4329
4330 let result_b_shared = db.find_fixture_definition(&test_b, 1, 29);
4331 assert!(result_b_shared.is_some());
4332 assert_eq!(
4333 result_b_shared.unwrap().file_path,
4334 root_conftest,
4335 "Test in module_b should access root's shared fixture"
4336 );
4337}
4338
4339#[test]
4340fn test_fixture_resolution_multiple_unrelated_branches_is_deterministic() {
4341 let db = FixtureDatabase::new();
4344
4345 let branch_a_content = r#"
4347import pytest
4348
4349@pytest.fixture
4350def common_fixture():
4351 return "branch_a"
4352"#;
4353 let branch_a_conftest = PathBuf::from("/tmp/projects/project_a/conftest.py");
4354 db.analyze_file(branch_a_conftest.clone(), branch_a_content);
4355
4356 let branch_b_content = r#"
4357import pytest
4358
4359@pytest.fixture
4360def common_fixture():
4361 return "branch_b"
4362"#;
4363 let branch_b_conftest = PathBuf::from("/tmp/projects/project_b/conftest.py");
4364 db.analyze_file(branch_b_conftest.clone(), branch_b_content);
4365
4366 let branch_c_content = r#"
4367import pytest
4368
4369@pytest.fixture
4370def common_fixture():
4371 return "branch_c"
4372"#;
4373 let branch_c_conftest = PathBuf::from("/tmp/projects/project_c/conftest.py");
4374 db.analyze_file(branch_c_conftest.clone(), branch_c_content);
4375
4376 let test_content = r#"
4378def test_something(common_fixture):
4379 assert common_fixture is not None
4380"#;
4381 let test_path = PathBuf::from("/tmp/unrelated/test_foo.py");
4382 db.analyze_file(test_path.clone(), test_content);
4383
4384 let mut results = Vec::new();
4386 for _ in 0..20 {
4387 let result = db.find_fixture_definition(&test_path, 1, 19);
4388 assert!(result.is_some(), "Should find a fixture");
4389 results.push(result.unwrap().file_path.clone());
4390 }
4391
4392 let first_result = &results[0];
4394 for (i, result) in results.iter().enumerate() {
4395 assert_eq!(
4396 result, first_result,
4397 "Iteration {}: fixture resolution should be deterministic, expected {:?} but got {:?}",
4398 i, first_result, result
4399 );
4400 }
4401}
4402
4403#[test]
4404fn test_fixture_resolution_conftest_at_various_depths() {
4405 let db = FixtureDatabase::new();
4407
4408 let deep_content = r#"
4410import pytest
4411
4412@pytest.fixture
4413def fixture_a():
4414 return "deep"
4415
4416@pytest.fixture
4417def fixture_b():
4418 return "deep"
4419"#;
4420 let deep_conftest = PathBuf::from("/tmp/project/src/module/tests/integration/conftest.py");
4421 db.analyze_file(deep_conftest.clone(), deep_content);
4422
4423 let mid_content = r#"
4425import pytest
4426
4427@pytest.fixture
4428def fixture_a():
4429 return "mid"
4430"#;
4431 let mid_conftest = PathBuf::from("/tmp/project/src/module/conftest.py");
4432 db.analyze_file(mid_conftest.clone(), mid_content);
4433
4434 let root_content = r#"
4436import pytest
4437
4438@pytest.fixture
4439def fixture_c():
4440 return "root"
4441"#;
4442 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4443 db.analyze_file(root_conftest.clone(), root_content);
4444
4445 let test_content = r#"
4447def test_all(fixture_a, fixture_b, fixture_c):
4448 assert fixture_a == "deep"
4449 assert fixture_b == "deep"
4450 assert fixture_c == "root"
4451"#;
4452 let test_path = PathBuf::from("/tmp/project/src/module/tests/integration/test_foo.py");
4453 db.analyze_file(test_path.clone(), test_content);
4454
4455 let result_a = db.find_fixture_definition(&test_path, 1, 13);
4457 assert!(result_a.is_some());
4458 assert_eq!(
4459 result_a.unwrap().file_path,
4460 deep_conftest,
4461 "fixture_a should resolve to closest conftest (deep)"
4462 );
4463
4464 let result_b = db.find_fixture_definition(&test_path, 1, 24);
4466 assert!(result_b.is_some());
4467 assert_eq!(
4468 result_b.unwrap().file_path,
4469 deep_conftest,
4470 "fixture_b should resolve to deep conftest"
4471 );
4472
4473 let result_c = db.find_fixture_definition(&test_path, 1, 35);
4475 assert!(result_c.is_some());
4476 assert_eq!(
4477 result_c.unwrap().file_path,
4478 root_conftest,
4479 "fixture_c should resolve to root conftest"
4480 );
4481
4482 let test_mid_content = r#"
4484def test_mid(fixture_a, fixture_c):
4485 assert fixture_a == "mid"
4486 assert fixture_c == "root"
4487"#;
4488 let test_mid_path = PathBuf::from("/tmp/project/src/module/test_bar.py");
4489 db.analyze_file(test_mid_path.clone(), test_mid_content);
4490
4491 let result_a_mid = db.find_fixture_definition(&test_mid_path, 1, 13);
4493 assert!(result_a_mid.is_some());
4494 assert_eq!(
4495 result_a_mid.unwrap().file_path,
4496 mid_conftest,
4497 "fixture_a from mid-level test should resolve to mid conftest"
4498 );
4499}