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 pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
1942 let mut available_fixtures = Vec::new();
1943 let mut seen_names = std::collections::HashSet::new();
1944
1945 for entry in self.definitions.iter() {
1947 let fixture_name = entry.key();
1948 for def in entry.value().iter() {
1949 if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
1950 available_fixtures.push(def.clone());
1951 seen_names.insert(fixture_name.clone());
1952 }
1953 }
1954 }
1955
1956 if let Some(mut current_dir) = file_path.parent() {
1958 loop {
1959 let conftest_path = current_dir.join("conftest.py");
1960
1961 for entry in self.definitions.iter() {
1962 let fixture_name = entry.key();
1963 for def in entry.value().iter() {
1964 if def.file_path == conftest_path
1965 && !seen_names.contains(fixture_name.as_str())
1966 {
1967 available_fixtures.push(def.clone());
1968 seen_names.insert(fixture_name.clone());
1969 }
1970 }
1971 }
1972
1973 match current_dir.parent() {
1975 Some(parent) => current_dir = parent,
1976 None => break,
1977 }
1978 }
1979 }
1980
1981 for entry in self.definitions.iter() {
1983 let fixture_name = entry.key();
1984 for def in entry.value().iter() {
1985 if def.file_path.to_string_lossy().contains("site-packages")
1986 && !seen_names.contains(fixture_name.as_str())
1987 {
1988 available_fixtures.push(def.clone());
1989 seen_names.insert(fixture_name.clone());
1990 }
1991 }
1992 }
1993
1994 available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
1996 available_fixtures
1997 }
1998
1999 pub fn is_inside_function(
2002 &self,
2003 file_path: &Path,
2004 line: u32,
2005 character: u32,
2006 ) -> Option<(String, bool, Vec<String>)> {
2007 let content: Arc<String> = if let Some(cached) = self.file_cache.get(file_path) {
2009 Arc::clone(cached.value())
2010 } else {
2011 Arc::new(std::fs::read_to_string(file_path).ok()?)
2012 };
2013
2014 let target_line = (line + 1) as usize; let parsed = parse(&content, Mode::Module, "").ok()?;
2018
2019 if let rustpython_parser::ast::Mod::Module(module) = parsed {
2020 return self.find_enclosing_function(
2021 &module.body,
2022 &content,
2023 target_line,
2024 character as usize,
2025 );
2026 }
2027
2028 None
2029 }
2030
2031 fn find_enclosing_function(
2032 &self,
2033 stmts: &[Stmt],
2034 content: &str,
2035 target_line: usize,
2036 _target_char: usize,
2037 ) -> Option<(String, bool, Vec<String>)> {
2038 for stmt in stmts {
2039 match stmt {
2040 Stmt::FunctionDef(func_def) => {
2041 let func_start_line = content[..func_def.range.start().to_usize()]
2042 .matches('\n')
2043 .count()
2044 + 1;
2045 let func_end_line = content[..func_def.range.end().to_usize()]
2046 .matches('\n')
2047 .count()
2048 + 1;
2049
2050 if target_line >= func_start_line && target_line <= func_end_line {
2052 let is_fixture = func_def
2053 .decorator_list
2054 .iter()
2055 .any(Self::is_fixture_decorator);
2056 let is_test = func_def.name.starts_with("test_");
2057
2058 if is_test || is_fixture {
2060 let params: Vec<String> = func_def
2061 .args
2062 .args
2063 .iter()
2064 .map(|arg| arg.def.arg.to_string())
2065 .collect();
2066
2067 return Some((func_def.name.to_string(), is_fixture, params));
2068 }
2069 }
2070 }
2071 Stmt::AsyncFunctionDef(func_def) => {
2072 let func_start_line = content[..func_def.range.start().to_usize()]
2073 .matches('\n')
2074 .count()
2075 + 1;
2076 let func_end_line = content[..func_def.range.end().to_usize()]
2077 .matches('\n')
2078 .count()
2079 + 1;
2080
2081 if target_line >= func_start_line && target_line <= func_end_line {
2082 let is_fixture = func_def
2083 .decorator_list
2084 .iter()
2085 .any(Self::is_fixture_decorator);
2086 let is_test = func_def.name.starts_with("test_");
2087
2088 if is_test || is_fixture {
2089 let params: Vec<String> = func_def
2090 .args
2091 .args
2092 .iter()
2093 .map(|arg| arg.def.arg.to_string())
2094 .collect();
2095
2096 return Some((func_def.name.to_string(), is_fixture, params));
2097 }
2098 }
2099 }
2100 _ => {}
2101 }
2102 }
2103
2104 None
2105 }
2106}
2107
2108#[cfg(test)]
2109mod tests {
2110 use super::*;
2111 use std::path::PathBuf;
2112
2113 #[test]
2114 fn test_fixture_definition_detection() {
2115 let db = FixtureDatabase::new();
2116
2117 let conftest_content = r#"
2118import pytest
2119
2120@pytest.fixture
2121def my_fixture():
2122 return 42
2123
2124@fixture
2125def another_fixture():
2126 return "hello"
2127"#;
2128
2129 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2130 db.analyze_file(conftest_path.clone(), conftest_content);
2131
2132 assert!(db.definitions.contains_key("my_fixture"));
2134 assert!(db.definitions.contains_key("another_fixture"));
2135
2136 let my_fixture_defs = db.definitions.get("my_fixture").unwrap();
2138 assert_eq!(my_fixture_defs.len(), 1);
2139 assert_eq!(my_fixture_defs[0].name, "my_fixture");
2140 assert_eq!(my_fixture_defs[0].file_path, conftest_path);
2141 }
2142
2143 #[test]
2144 fn test_fixture_usage_detection() {
2145 let db = FixtureDatabase::new();
2146
2147 let test_content = r#"
2148def test_something(my_fixture, another_fixture):
2149 assert my_fixture == 42
2150 assert another_fixture == "hello"
2151
2152def test_other(my_fixture):
2153 assert my_fixture > 0
2154"#;
2155
2156 let test_path = PathBuf::from("/tmp/test/test_example.py");
2157 db.analyze_file(test_path.clone(), test_content);
2158
2159 assert!(db.usages.contains_key(&test_path));
2161
2162 let usages = db.usages.get(&test_path).unwrap();
2163 assert!(usages.iter().any(|u| u.name == "my_fixture"));
2165 assert!(usages.iter().any(|u| u.name == "another_fixture"));
2166 }
2167
2168 #[test]
2169 fn test_go_to_definition() {
2170 let db = FixtureDatabase::new();
2171
2172 let conftest_content = r#"
2174import pytest
2175
2176@pytest.fixture
2177def my_fixture():
2178 return 42
2179"#;
2180
2181 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2182 db.analyze_file(conftest_path.clone(), conftest_content);
2183
2184 let test_content = r#"
2186def test_something(my_fixture):
2187 assert my_fixture == 42
2188"#;
2189
2190 let test_path = PathBuf::from("/tmp/test/test_example.py");
2191 db.analyze_file(test_path.clone(), test_content);
2192
2193 let definition = db.find_fixture_definition(&test_path, 1, 19);
2198
2199 assert!(definition.is_some(), "Definition should be found");
2200 let def = definition.unwrap();
2201 assert_eq!(def.name, "my_fixture");
2202 assert_eq!(def.file_path, conftest_path);
2203 }
2204
2205 #[test]
2206 fn test_fixture_decorator_variations() {
2207 let db = FixtureDatabase::new();
2208
2209 let conftest_content = r#"
2210import pytest
2211from pytest import fixture
2212
2213@pytest.fixture
2214def fixture1():
2215 pass
2216
2217@pytest.fixture()
2218def fixture2():
2219 pass
2220
2221@fixture
2222def fixture3():
2223 pass
2224
2225@fixture()
2226def fixture4():
2227 pass
2228"#;
2229
2230 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2231 db.analyze_file(conftest_path, conftest_content);
2232
2233 assert!(db.definitions.contains_key("fixture1"));
2235 assert!(db.definitions.contains_key("fixture2"));
2236 assert!(db.definitions.contains_key("fixture3"));
2237 assert!(db.definitions.contains_key("fixture4"));
2238 }
2239
2240 #[test]
2241 fn test_fixture_in_test_file() {
2242 let db = FixtureDatabase::new();
2243
2244 let test_content = r#"
2246import pytest
2247
2248@pytest.fixture
2249def local_fixture():
2250 return 42
2251
2252def test_something(local_fixture):
2253 assert local_fixture == 42
2254"#;
2255
2256 let test_path = PathBuf::from("/tmp/test/test_example.py");
2257 db.analyze_file(test_path.clone(), test_content);
2258
2259 assert!(db.definitions.contains_key("local_fixture"));
2261
2262 let local_fixture_defs = db.definitions.get("local_fixture").unwrap();
2263 assert_eq!(local_fixture_defs.len(), 1);
2264 assert_eq!(local_fixture_defs[0].name, "local_fixture");
2265 assert_eq!(local_fixture_defs[0].file_path, test_path);
2266
2267 assert!(db.usages.contains_key(&test_path));
2269 let usages = db.usages.get(&test_path).unwrap();
2270 assert!(usages.iter().any(|u| u.name == "local_fixture"));
2271
2272 let usage_line = usages
2274 .iter()
2275 .find(|u| u.name == "local_fixture")
2276 .map(|u| u.line)
2277 .unwrap();
2278
2279 let definition = db.find_fixture_definition(&test_path, (usage_line - 1) as u32, 19);
2281 assert!(
2282 definition.is_some(),
2283 "Should find definition for fixture in same file. Line: {}, char: 19",
2284 usage_line
2285 );
2286 let def = definition.unwrap();
2287 assert_eq!(def.name, "local_fixture");
2288 assert_eq!(def.file_path, test_path);
2289 }
2290
2291 #[test]
2292 fn test_async_test_functions() {
2293 let db = FixtureDatabase::new();
2294
2295 let test_content = r#"
2297import pytest
2298
2299@pytest.fixture
2300def my_fixture():
2301 return 42
2302
2303async def test_async_function(my_fixture):
2304 assert my_fixture == 42
2305
2306def test_sync_function(my_fixture):
2307 assert my_fixture == 42
2308"#;
2309
2310 let test_path = PathBuf::from("/tmp/test/test_async.py");
2311 db.analyze_file(test_path.clone(), test_content);
2312
2313 assert!(db.definitions.contains_key("my_fixture"));
2315
2316 assert!(db.usages.contains_key(&test_path));
2318 let usages = db.usages.get(&test_path).unwrap();
2319
2320 let fixture_usages: Vec<_> = usages.iter().filter(|u| u.name == "my_fixture").collect();
2322 assert_eq!(
2323 fixture_usages.len(),
2324 2,
2325 "Should detect fixture usage in both async and sync tests"
2326 );
2327 }
2328
2329 #[test]
2330 fn test_extract_word_at_position() {
2331 let db = FixtureDatabase::new();
2332
2333 let line = "def test_something(my_fixture):";
2335
2336 assert_eq!(
2338 db.extract_word_at_position(line, 19),
2339 Some("my_fixture".to_string())
2340 );
2341
2342 assert_eq!(
2344 db.extract_word_at_position(line, 20),
2345 Some("my_fixture".to_string())
2346 );
2347
2348 assert_eq!(
2350 db.extract_word_at_position(line, 28),
2351 Some("my_fixture".to_string())
2352 );
2353
2354 assert_eq!(
2356 db.extract_word_at_position(line, 0),
2357 Some("def".to_string())
2358 );
2359
2360 assert_eq!(db.extract_word_at_position(line, 3), None);
2362
2363 assert_eq!(
2365 db.extract_word_at_position(line, 4),
2366 Some("test_something".to_string())
2367 );
2368
2369 assert_eq!(db.extract_word_at_position(line, 18), None);
2371
2372 assert_eq!(db.extract_word_at_position(line, 29), None);
2374
2375 assert_eq!(db.extract_word_at_position(line, 31), None);
2377 }
2378
2379 #[test]
2380 fn test_extract_word_at_position_fixture_definition() {
2381 let db = FixtureDatabase::new();
2382
2383 let line = "@pytest.fixture";
2384
2385 assert_eq!(db.extract_word_at_position(line, 0), None);
2387
2388 assert_eq!(
2390 db.extract_word_at_position(line, 1),
2391 Some("pytest".to_string())
2392 );
2393
2394 assert_eq!(db.extract_word_at_position(line, 7), None);
2396
2397 assert_eq!(
2399 db.extract_word_at_position(line, 8),
2400 Some("fixture".to_string())
2401 );
2402
2403 let line2 = "def foo(other_fixture):";
2404
2405 assert_eq!(
2407 db.extract_word_at_position(line2, 0),
2408 Some("def".to_string())
2409 );
2410
2411 assert_eq!(db.extract_word_at_position(line2, 3), None);
2413
2414 assert_eq!(
2416 db.extract_word_at_position(line2, 4),
2417 Some("foo".to_string())
2418 );
2419
2420 assert_eq!(
2422 db.extract_word_at_position(line2, 8),
2423 Some("other_fixture".to_string())
2424 );
2425
2426 assert_eq!(db.extract_word_at_position(line2, 7), None);
2428 }
2429
2430 #[test]
2431 fn test_word_detection_only_on_fixtures() {
2432 let db = FixtureDatabase::new();
2433
2434 let conftest_content = r#"
2436import pytest
2437
2438@pytest.fixture
2439def my_fixture():
2440 return 42
2441"#;
2442 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2443 db.analyze_file(conftest_path.clone(), conftest_content);
2444
2445 let test_content = r#"
2447def test_something(my_fixture, regular_param):
2448 assert my_fixture == 42
2449"#;
2450 let test_path = PathBuf::from("/tmp/test/test_example.py");
2451 db.analyze_file(test_path.clone(), test_content);
2452
2453 assert_eq!(db.find_fixture_definition(&test_path, 1, 0), None);
2462
2463 assert_eq!(db.find_fixture_definition(&test_path, 1, 4), None);
2465
2466 let result = db.find_fixture_definition(&test_path, 1, 19);
2468 assert!(result.is_some());
2469 let def = result.unwrap();
2470 assert_eq!(def.name, "my_fixture");
2471
2472 assert_eq!(db.find_fixture_definition(&test_path, 1, 31), None);
2474
2475 assert_eq!(db.find_fixture_definition(&test_path, 1, 18), None); assert_eq!(db.find_fixture_definition(&test_path, 1, 29), None); }
2479
2480 #[test]
2481 fn test_self_referencing_fixture() {
2482 let db = FixtureDatabase::new();
2483
2484 let parent_conftest_content = r#"
2486import pytest
2487
2488@pytest.fixture
2489def foo():
2490 return "parent"
2491"#;
2492 let parent_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2493 db.analyze_file(parent_conftest_path.clone(), parent_conftest_content);
2494
2495 let child_conftest_content = r#"
2497import pytest
2498
2499@pytest.fixture
2500def foo(foo):
2501 return foo + " child"
2502"#;
2503 let child_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2504 db.analyze_file(child_conftest_path.clone(), child_conftest_content);
2505
2506 let result = db.find_fixture_definition(&child_conftest_path, 4, 8);
2512
2513 assert!(
2514 result.is_some(),
2515 "Should find parent definition for self-referencing fixture"
2516 );
2517 let def = result.unwrap();
2518 assert_eq!(def.name, "foo");
2519 assert_eq!(
2520 def.file_path, parent_conftest_path,
2521 "Should resolve to parent conftest.py, not the child"
2522 );
2523 assert_eq!(def.line, 5, "Should point to line 5 of parent conftest.py");
2524 }
2525
2526 #[test]
2527 fn test_fixture_overriding_same_file() {
2528 let db = FixtureDatabase::new();
2529
2530 let test_content = r#"
2532import pytest
2533
2534@pytest.fixture
2535def my_fixture():
2536 return "first"
2537
2538@pytest.fixture
2539def my_fixture():
2540 return "second"
2541
2542def test_something(my_fixture):
2543 assert my_fixture == "second"
2544"#;
2545 let test_path = PathBuf::from("/tmp/test/test_example.py");
2546 db.analyze_file(test_path.clone(), test_content);
2547
2548 let result = db.find_fixture_definition(&test_path, 11, 19);
2557
2558 assert!(result.is_some(), "Should find fixture definition");
2559 let def = result.unwrap();
2560 assert_eq!(def.name, "my_fixture");
2561 assert_eq!(def.file_path, test_path);
2562 }
2566
2567 #[test]
2568 fn test_fixture_overriding_conftest_hierarchy() {
2569 let db = FixtureDatabase::new();
2570
2571 let root_conftest_content = r#"
2573import pytest
2574
2575@pytest.fixture
2576def shared_fixture():
2577 return "root"
2578"#;
2579 let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2580 db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2581
2582 let sub_conftest_content = r#"
2584import pytest
2585
2586@pytest.fixture
2587def shared_fixture():
2588 return "subdir"
2589"#;
2590 let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2591 db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2592
2593 let test_content = r#"
2595def test_something(shared_fixture):
2596 assert shared_fixture == "subdir"
2597"#;
2598 let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
2599 db.analyze_file(test_path.clone(), test_content);
2600
2601 let result = db.find_fixture_definition(&test_path, 1, 19);
2607
2608 assert!(result.is_some(), "Should find fixture definition");
2609 let def = result.unwrap();
2610 assert_eq!(def.name, "shared_fixture");
2611 assert_eq!(
2612 def.file_path, sub_conftest_path,
2613 "Should resolve to closest conftest.py"
2614 );
2615
2616 let parent_test_content = r#"
2618def test_parent(shared_fixture):
2619 assert shared_fixture == "root"
2620"#;
2621 let parent_test_path = PathBuf::from("/tmp/test/test_parent.py");
2622 db.analyze_file(parent_test_path.clone(), parent_test_content);
2623
2624 let result = db.find_fixture_definition(&parent_test_path, 1, 16);
2625
2626 assert!(result.is_some(), "Should find fixture definition");
2627 let def = result.unwrap();
2628 assert_eq!(def.name, "shared_fixture");
2629 assert_eq!(
2630 def.file_path, root_conftest_path,
2631 "Should resolve to root conftest.py"
2632 );
2633 }
2634
2635 #[test]
2636 fn test_scoped_references() {
2637 let db = FixtureDatabase::new();
2638
2639 let root_conftest_content = r#"
2641import pytest
2642
2643@pytest.fixture
2644def shared_fixture():
2645 return "root"
2646"#;
2647 let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2648 db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2649
2650 let sub_conftest_content = r#"
2652import pytest
2653
2654@pytest.fixture
2655def shared_fixture():
2656 return "subdir"
2657"#;
2658 let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2659 db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2660
2661 let root_test_content = r#"
2663def test_root(shared_fixture):
2664 assert shared_fixture == "root"
2665"#;
2666 let root_test_path = PathBuf::from("/tmp/test/test_root.py");
2667 db.analyze_file(root_test_path.clone(), root_test_content);
2668
2669 let sub_test_content = r#"
2671def test_sub(shared_fixture):
2672 assert shared_fixture == "subdir"
2673"#;
2674 let sub_test_path = PathBuf::from("/tmp/test/subdir/test_sub.py");
2675 db.analyze_file(sub_test_path.clone(), sub_test_content);
2676
2677 let sub_test2_content = r#"
2679def test_sub2(shared_fixture):
2680 assert shared_fixture == "subdir"
2681"#;
2682 let sub_test2_path = PathBuf::from("/tmp/test/subdir/test_sub2.py");
2683 db.analyze_file(sub_test2_path.clone(), sub_test2_content);
2684
2685 let root_definitions = db.definitions.get("shared_fixture").unwrap();
2687 let root_definition = root_definitions
2688 .iter()
2689 .find(|d| d.file_path == root_conftest_path)
2690 .unwrap();
2691
2692 let sub_definition = root_definitions
2694 .iter()
2695 .find(|d| d.file_path == sub_conftest_path)
2696 .unwrap();
2697
2698 let root_refs = db.find_references_for_definition(root_definition);
2700
2701 assert_eq!(
2703 root_refs.len(),
2704 1,
2705 "Root definition should have 1 reference (from root test)"
2706 );
2707 assert_eq!(root_refs[0].file_path, root_test_path);
2708
2709 let sub_refs = db.find_references_for_definition(sub_definition);
2711
2712 assert_eq!(
2714 sub_refs.len(),
2715 2,
2716 "Subdir definition should have 2 references (from subdir tests)"
2717 );
2718
2719 let sub_ref_paths: Vec<_> = sub_refs.iter().map(|r| &r.file_path).collect();
2720 assert!(sub_ref_paths.contains(&&sub_test_path));
2721 assert!(sub_ref_paths.contains(&&sub_test2_path));
2722
2723 let all_refs = db.find_fixture_references("shared_fixture");
2725 assert_eq!(
2726 all_refs.len(),
2727 3,
2728 "Should find 3 total references across all scopes"
2729 );
2730 }
2731
2732 #[test]
2733 fn test_multiline_parameters() {
2734 let db = FixtureDatabase::new();
2735
2736 let conftest_content = r#"
2738import pytest
2739
2740@pytest.fixture
2741def foo():
2742 return 42
2743"#;
2744 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2745 db.analyze_file(conftest_path.clone(), conftest_content);
2746
2747 let test_content = r#"
2749def test_xxx(
2750 foo,
2751):
2752 assert foo == 42
2753"#;
2754 let test_path = PathBuf::from("/tmp/test/test_example.py");
2755 db.analyze_file(test_path.clone(), test_content);
2756
2757 if let Some(usages) = db.usages.get(&test_path) {
2763 println!("Usages recorded:");
2764 for usage in usages.iter() {
2765 println!(" {} at line {} (1-indexed)", usage.name, usage.line);
2766 }
2767 } else {
2768 println!("No usages recorded for test file");
2769 }
2770
2771 let result = db.find_fixture_definition(&test_path, 2, 4);
2780
2781 assert!(
2782 result.is_some(),
2783 "Should find fixture definition when cursor is on parameter line"
2784 );
2785 let def = result.unwrap();
2786 assert_eq!(def.name, "foo");
2787 }
2788
2789 #[test]
2790 fn test_find_references_from_usage() {
2791 let db = FixtureDatabase::new();
2792
2793 let test_content = r#"
2795import pytest
2796
2797@pytest.fixture
2798def foo(): ...
2799
2800
2801def test_xxx(foo):
2802 pass
2803"#;
2804 let test_path = PathBuf::from("/tmp/test/test_example.py");
2805 db.analyze_file(test_path.clone(), test_content);
2806
2807 let foo_defs = db.definitions.get("foo").unwrap();
2809 assert_eq!(foo_defs.len(), 1, "Should have exactly one foo definition");
2810 let foo_def = &foo_defs[0];
2811 assert_eq!(foo_def.line, 5, "foo definition should be on line 5");
2812
2813 let refs_from_def = db.find_references_for_definition(foo_def);
2815 println!("References from definition:");
2816 for r in &refs_from_def {
2817 println!(" {} at line {}", r.name, r.line);
2818 }
2819
2820 assert_eq!(
2821 refs_from_def.len(),
2822 1,
2823 "Should find 1 usage reference (test_xxx parameter)"
2824 );
2825 assert_eq!(refs_from_def[0].line, 8, "Usage should be on line 8");
2826
2827 let fixture_name = db.find_fixture_at_position(&test_path, 7, 13);
2830 println!(
2831 "\nfind_fixture_at_position(line 7, char 13): {:?}",
2832 fixture_name
2833 );
2834
2835 assert_eq!(
2836 fixture_name,
2837 Some("foo".to_string()),
2838 "Should find fixture name at usage position"
2839 );
2840
2841 let resolved_def = db.find_fixture_definition(&test_path, 7, 13);
2842 println!(
2843 "\nfind_fixture_definition(line 7, char 13): {:?}",
2844 resolved_def.as_ref().map(|d| (d.line, &d.file_path))
2845 );
2846
2847 assert!(resolved_def.is_some(), "Should resolve usage to definition");
2848 assert_eq!(
2849 resolved_def.unwrap(),
2850 *foo_def,
2851 "Should resolve to the correct definition"
2852 );
2853 }
2854
2855 #[test]
2856 fn test_find_references_with_ellipsis_body() {
2857 let db = FixtureDatabase::new();
2859
2860 let test_content = r#"@pytest.fixture
2861def foo(): ...
2862
2863
2864def test_xxx(foo):
2865 pass
2866"#;
2867 let test_path = PathBuf::from("/tmp/test/test_codegen.py");
2868 db.analyze_file(test_path.clone(), test_content);
2869
2870 let foo_defs = db.definitions.get("foo");
2872 println!(
2873 "foo definitions: {:?}",
2874 foo_defs
2875 .as_ref()
2876 .map(|defs| defs.iter().map(|d| d.line).collect::<Vec<_>>())
2877 );
2878
2879 if let Some(usages) = db.usages.get(&test_path) {
2881 println!("usages:");
2882 for u in usages.iter() {
2883 println!(" {} at line {}", u.name, u.line);
2884 }
2885 }
2886
2887 assert!(foo_defs.is_some(), "Should find foo definition");
2888 let foo_def = &foo_defs.unwrap()[0];
2889
2890 let usages = db.usages.get(&test_path).unwrap();
2892 let foo_usage = usages.iter().find(|u| u.name == "foo").unwrap();
2893
2894 let usage_lsp_line = (foo_usage.line - 1) as u32;
2896 println!("\nTesting from usage at LSP line {}", usage_lsp_line);
2897
2898 let fixture_name = db.find_fixture_at_position(&test_path, usage_lsp_line, 13);
2899 assert_eq!(
2900 fixture_name,
2901 Some("foo".to_string()),
2902 "Should find foo at usage"
2903 );
2904
2905 let def_from_usage = db.find_fixture_definition(&test_path, usage_lsp_line, 13);
2906 assert!(
2907 def_from_usage.is_some(),
2908 "Should resolve usage to definition"
2909 );
2910 assert_eq!(def_from_usage.unwrap(), *foo_def);
2911 }
2912
2913 #[test]
2914 fn test_fixture_hierarchy_parent_references() {
2915 let db = FixtureDatabase::new();
2918
2919 let parent_content = r#"
2921import pytest
2922
2923@pytest.fixture
2924def cli_runner():
2925 """Parent fixture"""
2926 return "parent"
2927"#;
2928 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2929 db.analyze_file(parent_conftest.clone(), parent_content);
2930
2931 let child_content = r#"
2933import pytest
2934
2935@pytest.fixture
2936def cli_runner(cli_runner):
2937 """Child override that uses parent"""
2938 return cli_runner
2939"#;
2940 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2941 db.analyze_file(child_conftest.clone(), child_content);
2942
2943 let test_content = r#"
2945def test_one(cli_runner):
2946 pass
2947
2948def test_two(cli_runner):
2949 pass
2950"#;
2951 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2952 db.analyze_file(test_path.clone(), test_content);
2953
2954 let parent_defs = db.definitions.get("cli_runner").unwrap();
2956 let parent_def = parent_defs
2957 .iter()
2958 .find(|d| d.file_path == parent_conftest)
2959 .unwrap();
2960
2961 println!(
2962 "\nParent definition: {:?}:{}",
2963 parent_def.file_path, parent_def.line
2964 );
2965
2966 let refs = db.find_references_for_definition(parent_def);
2968
2969 println!("\nReferences for parent definition:");
2970 for r in &refs {
2971 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2972 }
2973
2974 assert!(
2981 refs.len() <= 2,
2982 "Parent should have at most 2 references: child definition and its parameter, got {}",
2983 refs.len()
2984 );
2985
2986 let child_refs: Vec<_> = refs
2988 .iter()
2989 .filter(|r| r.file_path == child_conftest)
2990 .collect();
2991 assert!(
2992 !child_refs.is_empty(),
2993 "Parent references should include child fixture definition"
2994 );
2995
2996 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2998 assert!(
2999 test_refs.is_empty(),
3000 "Parent references should NOT include child's test file usages"
3001 );
3002 }
3003
3004 #[test]
3005 fn test_fixture_hierarchy_child_references() {
3006 let db = FixtureDatabase::new();
3009
3010 let parent_content = r#"
3012import pytest
3013
3014@pytest.fixture
3015def cli_runner():
3016 return "parent"
3017"#;
3018 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
3019 db.analyze_file(parent_conftest.clone(), parent_content);
3020
3021 let child_content = r#"
3023import pytest
3024
3025@pytest.fixture
3026def cli_runner(cli_runner):
3027 return cli_runner
3028"#;
3029 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
3030 db.analyze_file(child_conftest.clone(), child_content);
3031
3032 let test_content = r#"
3034def test_one(cli_runner):
3035 pass
3036
3037def test_two(cli_runner):
3038 pass
3039"#;
3040 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
3041 db.analyze_file(test_path.clone(), test_content);
3042
3043 let child_defs = db.definitions.get("cli_runner").unwrap();
3045 let child_def = child_defs
3046 .iter()
3047 .find(|d| d.file_path == child_conftest)
3048 .unwrap();
3049
3050 println!(
3051 "\nChild definition: {:?}:{}",
3052 child_def.file_path, child_def.line
3053 );
3054
3055 let refs = db.find_references_for_definition(child_def);
3057
3058 println!("\nReferences for child definition:");
3059 for r in &refs {
3060 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
3061 }
3062
3063 assert!(
3065 refs.len() >= 2,
3066 "Child should have at least 2 references from test file, got {}",
3067 refs.len()
3068 );
3069
3070 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
3071 assert_eq!(
3072 test_refs.len(),
3073 2,
3074 "Should have 2 references from test file"
3075 );
3076 }
3077
3078 #[test]
3079 fn test_fixture_hierarchy_child_parameter_references() {
3080 let db = FixtureDatabase::new();
3083
3084 let parent_content = r#"
3086import pytest
3087
3088@pytest.fixture
3089def cli_runner():
3090 return "parent"
3091"#;
3092 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
3093 db.analyze_file(parent_conftest.clone(), parent_content);
3094
3095 let child_content = r#"
3097import pytest
3098
3099@pytest.fixture
3100def cli_runner(cli_runner):
3101 return cli_runner
3102"#;
3103 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
3104 db.analyze_file(child_conftest.clone(), child_content);
3105
3106 let resolved_def = db.find_fixture_definition(&child_conftest, 4, 15);
3110
3111 assert!(
3112 resolved_def.is_some(),
3113 "Child parameter should resolve to parent definition"
3114 );
3115
3116 let def = resolved_def.unwrap();
3117 assert_eq!(
3118 def.file_path, parent_conftest,
3119 "Should resolve to parent conftest"
3120 );
3121
3122 let refs = db.find_references_for_definition(&def);
3124
3125 println!("\nReferences for parent (from child parameter):");
3126 for r in &refs {
3127 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
3128 }
3129
3130 let child_refs: Vec<_> = refs
3132 .iter()
3133 .filter(|r| r.file_path == child_conftest)
3134 .collect();
3135 assert!(
3136 !child_refs.is_empty(),
3137 "Parent references should include child fixture parameter"
3138 );
3139 }
3140
3141 #[test]
3142 fn test_fixture_hierarchy_usage_from_test() {
3143 let db = FixtureDatabase::new();
3146
3147 let parent_content = r#"
3149import pytest
3150
3151@pytest.fixture
3152def cli_runner():
3153 return "parent"
3154"#;
3155 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
3156 db.analyze_file(parent_conftest.clone(), parent_content);
3157
3158 let child_content = r#"
3160import pytest
3161
3162@pytest.fixture
3163def cli_runner(cli_runner):
3164 return cli_runner
3165"#;
3166 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
3167 db.analyze_file(child_conftest.clone(), child_content);
3168
3169 let test_content = r#"
3171def test_one(cli_runner):
3172 pass
3173
3174def test_two(cli_runner):
3175 pass
3176
3177def test_three(cli_runner):
3178 pass
3179"#;
3180 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
3181 db.analyze_file(test_path.clone(), test_content);
3182
3183 let resolved_def = db.find_fixture_definition(&test_path, 1, 13);
3185
3186 assert!(
3187 resolved_def.is_some(),
3188 "Usage should resolve to child definition"
3189 );
3190
3191 let def = resolved_def.unwrap();
3192 assert_eq!(
3193 def.file_path, child_conftest,
3194 "Should resolve to child conftest (not parent)"
3195 );
3196
3197 let refs = db.find_references_for_definition(&def);
3199
3200 println!("\nReferences for child (from test usage):");
3201 for r in &refs {
3202 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
3203 }
3204
3205 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
3207 assert_eq!(test_refs.len(), 3, "Should find all 3 usages in test file");
3208 }
3209
3210 #[test]
3211 fn test_fixture_hierarchy_multiple_levels() {
3212 let db = FixtureDatabase::new();
3214
3215 let grandparent_content = r#"
3217import pytest
3218
3219@pytest.fixture
3220def db():
3221 return "grandparent_db"
3222"#;
3223 let grandparent_conftest = PathBuf::from("/tmp/project/conftest.py");
3224 db.analyze_file(grandparent_conftest.clone(), grandparent_content);
3225
3226 let parent_content = r#"
3228import pytest
3229
3230@pytest.fixture
3231def db(db):
3232 return f"parent_{db}"
3233"#;
3234 let parent_conftest = PathBuf::from("/tmp/project/api/conftest.py");
3235 db.analyze_file(parent_conftest.clone(), parent_content);
3236
3237 let child_content = r#"
3239import pytest
3240
3241@pytest.fixture
3242def db(db):
3243 return f"child_{db}"
3244"#;
3245 let child_conftest = PathBuf::from("/tmp/project/api/tests/conftest.py");
3246 db.analyze_file(child_conftest.clone(), child_content);
3247
3248 let test_content = r#"
3250def test_db(db):
3251 pass
3252"#;
3253 let test_path = PathBuf::from("/tmp/project/api/tests/test_example.py");
3254 db.analyze_file(test_path.clone(), test_content);
3255
3256 let all_defs = db.definitions.get("db").unwrap();
3258 assert_eq!(all_defs.len(), 3, "Should have 3 definitions");
3259
3260 let grandparent_def = all_defs
3261 .iter()
3262 .find(|d| d.file_path == grandparent_conftest)
3263 .unwrap();
3264 let parent_def = all_defs
3265 .iter()
3266 .find(|d| d.file_path == parent_conftest)
3267 .unwrap();
3268 let child_def = all_defs
3269 .iter()
3270 .find(|d| d.file_path == child_conftest)
3271 .unwrap();
3272
3273 let resolved = db.find_fixture_definition(&test_path, 1, 12);
3275 assert_eq!(
3276 resolved.as_ref(),
3277 Some(child_def),
3278 "Test should use child definition"
3279 );
3280
3281 let child_refs = db.find_references_for_definition(child_def);
3283 let test_refs: Vec<_> = child_refs
3284 .iter()
3285 .filter(|r| r.file_path == test_path)
3286 .collect();
3287 assert!(
3288 !test_refs.is_empty(),
3289 "Child should have test file references"
3290 );
3291
3292 let parent_refs = db.find_references_for_definition(parent_def);
3294 let child_param_refs: Vec<_> = parent_refs
3295 .iter()
3296 .filter(|r| r.file_path == child_conftest)
3297 .collect();
3298 let test_refs_in_parent: Vec<_> = parent_refs
3299 .iter()
3300 .filter(|r| r.file_path == test_path)
3301 .collect();
3302
3303 assert!(
3304 !child_param_refs.is_empty(),
3305 "Parent should have child parameter reference"
3306 );
3307 assert!(
3308 test_refs_in_parent.is_empty(),
3309 "Parent should NOT have test file references"
3310 );
3311
3312 let grandparent_refs = db.find_references_for_definition(grandparent_def);
3314 let parent_param_refs: Vec<_> = grandparent_refs
3315 .iter()
3316 .filter(|r| r.file_path == parent_conftest)
3317 .collect();
3318 let child_refs_in_gp: Vec<_> = grandparent_refs
3319 .iter()
3320 .filter(|r| r.file_path == child_conftest)
3321 .collect();
3322
3323 assert!(
3324 !parent_param_refs.is_empty(),
3325 "Grandparent should have parent parameter reference"
3326 );
3327 assert!(
3328 child_refs_in_gp.is_empty(),
3329 "Grandparent should NOT have child references"
3330 );
3331 }
3332
3333 #[test]
3334 fn test_fixture_hierarchy_same_file_override() {
3335 let db = FixtureDatabase::new();
3338
3339 let content = r#"
3340import pytest
3341
3342@pytest.fixture
3343def base():
3344 return "base"
3345
3346@pytest.fixture
3347def base(base):
3348 return f"override_{base}"
3349
3350def test_uses_override(base):
3351 pass
3352"#;
3353 let test_path = PathBuf::from("/tmp/test/test_example.py");
3354 db.analyze_file(test_path.clone(), content);
3355
3356 let defs = db.definitions.get("base").unwrap();
3357 assert_eq!(defs.len(), 2, "Should have 2 definitions in same file");
3358
3359 println!("\nDefinitions found:");
3360 for d in defs.iter() {
3361 println!(" base at line {}", d.line);
3362 }
3363
3364 if let Some(usages) = db.usages.get(&test_path) {
3366 println!("\nUsages found:");
3367 for u in usages.iter() {
3368 println!(" {} at line {}", u.name, u.line);
3369 }
3370 } else {
3371 println!("\nNo usages found!");
3372 }
3373
3374 let resolved = db.find_fixture_definition(&test_path, 11, 23);
3378
3379 println!("\nResolved: {:?}", resolved.as_ref().map(|d| d.line));
3380
3381 assert!(resolved.is_some(), "Should resolve to override definition");
3382
3383 let override_def = defs.iter().find(|d| d.line == 9).unwrap();
3385 println!("Override def at line: {}", override_def.line);
3386 assert_eq!(resolved.as_ref(), Some(override_def));
3387 }
3388
3389 #[test]
3390 fn test_cursor_position_on_definition_line() {
3391 let db = FixtureDatabase::new();
3394
3395 let parent_content = r#"
3397import pytest
3398
3399@pytest.fixture
3400def cli_runner():
3401 return "parent"
3402"#;
3403 let parent_conftest = PathBuf::from("/tmp/conftest.py");
3404 db.analyze_file(parent_conftest.clone(), parent_content);
3405
3406 let content = r#"
3407import pytest
3408
3409@pytest.fixture
3410def cli_runner(cli_runner):
3411 return cli_runner
3412"#;
3413 let test_path = PathBuf::from("/tmp/test/test_example.py");
3414 db.analyze_file(test_path.clone(), content);
3415
3416 println!("\n=== Testing character positions on line 5 ===");
3423
3424 if let Some(usages) = db.usages.get(&test_path) {
3426 println!("\nUsages found:");
3427 for u in usages.iter() {
3428 println!(
3429 " {} at line {}, chars {}-{}",
3430 u.name, u.line, u.start_char, u.end_char
3431 );
3432 }
3433 } else {
3434 println!("\nNo usages found!");
3435 }
3436
3437 let line_content = "def cli_runner(cli_runner):";
3439 println!("\nLine content: '{}'", line_content);
3440
3441 println!("\nPosition 4 (function name):");
3443 let word_at_4 = db.extract_word_at_position(line_content, 4);
3444 println!(" Word at cursor: {:?}", word_at_4);
3445 let fixture_name_at_4 = db.find_fixture_at_position(&test_path, 4, 4);
3446 println!(" find_fixture_at_position: {:?}", fixture_name_at_4);
3447 let resolved_4 = db.find_fixture_definition(&test_path, 4, 4); println!(
3449 " Resolved: {:?}",
3450 resolved_4.as_ref().map(|d| (d.name.as_str(), d.line))
3451 );
3452
3453 println!("\nPosition 16 (parameter name):");
3455 let word_at_16 = db.extract_word_at_position(line_content, 16);
3456 println!(" Word at cursor: {:?}", word_at_16);
3457
3458 if let Some(usages) = db.usages.get(&test_path) {
3460 for usage in usages.iter() {
3461 println!(" Checking usage: {} at line {}", usage.name, usage.line);
3462 if usage.line == 5 && usage.name == "cli_runner" {
3463 println!(" MATCH! Usage matches our position");
3464 }
3465 }
3466 }
3467
3468 let fixture_name_at_16 = db.find_fixture_at_position(&test_path, 4, 16);
3469 println!(" find_fixture_at_position: {:?}", fixture_name_at_16);
3470 let resolved_16 = db.find_fixture_definition(&test_path, 4, 16); println!(
3472 " Resolved: {:?}",
3473 resolved_16.as_ref().map(|d| (d.name.as_str(), d.line))
3474 );
3475
3476 assert_eq!(word_at_4, Some("cli_runner".to_string()));
3481 assert_eq!(word_at_16, Some("cli_runner".to_string()));
3482
3483 println!("\n=== ACTUAL vs EXPECTED ===");
3485 println!("Position 4 (function name):");
3486 println!(
3487 " Actual: {:?}",
3488 resolved_4.as_ref().map(|d| (&d.file_path, d.line))
3489 );
3490 println!(" Expected: test file, line 5 (the child definition itself)");
3491
3492 println!("\nPosition 16 (parameter):");
3493 println!(
3494 " Actual: {:?}",
3495 resolved_16.as_ref().map(|d| (&d.file_path, d.line))
3496 );
3497 println!(" Expected: conftest, line 5 (the parent definition)");
3498
3499 if let Some(ref def) = resolved_16 {
3505 assert_eq!(
3506 def.file_path, parent_conftest,
3507 "Parameter should resolve to parent definition"
3508 );
3509 } else {
3510 panic!("Position 16 (parameter) should resolve to parent definition");
3511 }
3512 }
3513
3514 #[test]
3515 fn test_undeclared_fixture_detection_in_test() {
3516 let db = FixtureDatabase::new();
3517
3518 let conftest_content = r#"
3520import pytest
3521
3522@pytest.fixture
3523def my_fixture():
3524 return 42
3525"#;
3526 let conftest_path = PathBuf::from("/tmp/conftest.py");
3527 db.analyze_file(conftest_path.clone(), conftest_content);
3528
3529 let test_content = r#"
3531def test_example():
3532 result = my_fixture.get()
3533 assert result == 42
3534"#;
3535 let test_path = PathBuf::from("/tmp/test_example.py");
3536 db.analyze_file(test_path.clone(), test_content);
3537
3538 let undeclared = db.get_undeclared_fixtures(&test_path);
3540 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3541
3542 let fixture = &undeclared[0];
3543 assert_eq!(fixture.name, "my_fixture");
3544 assert_eq!(fixture.function_name, "test_example");
3545 assert_eq!(fixture.line, 3); }
3547
3548 #[test]
3549 fn test_undeclared_fixture_detection_in_fixture() {
3550 let db = FixtureDatabase::new();
3551
3552 let conftest_content = r#"
3554import pytest
3555
3556@pytest.fixture
3557def base_fixture():
3558 return "base"
3559
3560@pytest.fixture
3561def helper_fixture():
3562 return "helper"
3563"#;
3564 let conftest_path = PathBuf::from("/tmp/conftest.py");
3565 db.analyze_file(conftest_path.clone(), conftest_content);
3566
3567 let test_content = r#"
3569import pytest
3570
3571@pytest.fixture
3572def my_fixture(base_fixture):
3573 data = helper_fixture.value
3574 return f"{base_fixture}-{data}"
3575"#;
3576 let test_path = PathBuf::from("/tmp/test_example.py");
3577 db.analyze_file(test_path.clone(), test_content);
3578
3579 let undeclared = db.get_undeclared_fixtures(&test_path);
3581 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3582
3583 let fixture = &undeclared[0];
3584 assert_eq!(fixture.name, "helper_fixture");
3585 assert_eq!(fixture.function_name, "my_fixture");
3586 assert_eq!(fixture.line, 6); }
3588
3589 #[test]
3590 fn test_no_false_positive_for_declared_fixtures() {
3591 let db = FixtureDatabase::new();
3592
3593 let conftest_content = r#"
3595import pytest
3596
3597@pytest.fixture
3598def my_fixture():
3599 return 42
3600"#;
3601 let conftest_path = PathBuf::from("/tmp/conftest.py");
3602 db.analyze_file(conftest_path.clone(), conftest_content);
3603
3604 let test_content = r#"
3606def test_example(my_fixture):
3607 result = my_fixture
3608 assert result == 42
3609"#;
3610 let test_path = PathBuf::from("/tmp/test_example.py");
3611 db.analyze_file(test_path.clone(), test_content);
3612
3613 let undeclared = db.get_undeclared_fixtures(&test_path);
3615 assert_eq!(
3616 undeclared.len(),
3617 0,
3618 "Should not detect any undeclared fixtures"
3619 );
3620 }
3621
3622 #[test]
3623 fn test_no_false_positive_for_non_fixtures() {
3624 let db = FixtureDatabase::new();
3625
3626 let test_content = r#"
3628def test_example():
3629 my_variable = 42
3630 result = my_variable + 10
3631 assert result == 52
3632"#;
3633 let test_path = PathBuf::from("/tmp/test_example.py");
3634 db.analyze_file(test_path.clone(), test_content);
3635
3636 let undeclared = db.get_undeclared_fixtures(&test_path);
3638 assert_eq!(
3639 undeclared.len(),
3640 0,
3641 "Should not detect any undeclared fixtures"
3642 );
3643 }
3644
3645 #[test]
3646 fn test_undeclared_fixture_not_available_in_hierarchy() {
3647 let db = FixtureDatabase::new();
3648
3649 let other_conftest = r#"
3651import pytest
3652
3653@pytest.fixture
3654def other_fixture():
3655 return "other"
3656"#;
3657 let other_path = PathBuf::from("/other/conftest.py");
3658 db.analyze_file(other_path.clone(), other_conftest);
3659
3660 let test_content = r#"
3662def test_example():
3663 result = other_fixture.value
3664 assert result == "other"
3665"#;
3666 let test_path = PathBuf::from("/tmp/test_example.py");
3667 db.analyze_file(test_path.clone(), test_content);
3668
3669 let undeclared = db.get_undeclared_fixtures(&test_path);
3671 assert_eq!(
3672 undeclared.len(),
3673 0,
3674 "Should not detect fixtures not in hierarchy"
3675 );
3676 }
3677}
3678
3679#[test]
3680fn test_undeclared_fixture_in_async_test() {
3681 let db = FixtureDatabase::new();
3682
3683 let content = r#"
3685import pytest
3686
3687@pytest.fixture
3688def http_client():
3689 return "MockClient"
3690
3691async def test_with_undeclared():
3692 response = await http_client.query("test")
3693 assert response == "test"
3694"#;
3695 let test_path = PathBuf::from("/tmp/test_example.py");
3696 db.analyze_file(test_path.clone(), content);
3697
3698 let undeclared = db.get_undeclared_fixtures(&test_path);
3700
3701 println!("Found {} undeclared fixtures", undeclared.len());
3702 for u in &undeclared {
3703 println!(" - {} at line {} in {}", u.name, u.line, u.function_name);
3704 }
3705
3706 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3707 assert_eq!(undeclared[0].name, "http_client");
3708 assert_eq!(undeclared[0].function_name, "test_with_undeclared");
3709 assert_eq!(undeclared[0].line, 9);
3710}
3711
3712#[test]
3713fn test_undeclared_fixture_in_assert_statement() {
3714 let db = FixtureDatabase::new();
3715
3716 let conftest_content = r#"
3718import pytest
3719
3720@pytest.fixture
3721def expected_value():
3722 return 42
3723"#;
3724 let conftest_path = PathBuf::from("/tmp/conftest.py");
3725 db.analyze_file(conftest_path.clone(), conftest_content);
3726
3727 let test_content = r#"
3729def test_assertion():
3730 result = calculate_value()
3731 assert result == expected_value
3732"#;
3733 let test_path = PathBuf::from("/tmp/test_example.py");
3734 db.analyze_file(test_path.clone(), test_content);
3735
3736 let undeclared = db.get_undeclared_fixtures(&test_path);
3738
3739 assert_eq!(
3740 undeclared.len(),
3741 1,
3742 "Should detect one undeclared fixture in assert"
3743 );
3744 assert_eq!(undeclared[0].name, "expected_value");
3745 assert_eq!(undeclared[0].function_name, "test_assertion");
3746}
3747
3748#[test]
3749fn test_no_false_positive_for_local_variable() {
3750 let db = FixtureDatabase::new();
3752
3753 let conftest_content = r#"
3755import pytest
3756
3757@pytest.fixture
3758def foo():
3759 return "fixture"
3760"#;
3761 let conftest_path = PathBuf::from("/tmp/conftest.py");
3762 db.analyze_file(conftest_path.clone(), conftest_content);
3763
3764 let test_content = r#"
3766def test_with_local_variable():
3767 foo = "local variable"
3768 result = foo.upper()
3769 assert result == "LOCAL VARIABLE"
3770"#;
3771 let test_path = PathBuf::from("/tmp/test_example.py");
3772 db.analyze_file(test_path.clone(), test_content);
3773
3774 let undeclared = db.get_undeclared_fixtures(&test_path);
3776
3777 assert_eq!(
3778 undeclared.len(),
3779 0,
3780 "Should not detect undeclared fixture when name is a local variable"
3781 );
3782}
3783
3784#[test]
3785fn test_no_false_positive_for_imported_name() {
3786 let db = FixtureDatabase::new();
3788
3789 let conftest_content = r#"
3791import pytest
3792
3793@pytest.fixture
3794def foo():
3795 return "fixture"
3796"#;
3797 let conftest_path = PathBuf::from("/tmp/conftest.py");
3798 db.analyze_file(conftest_path.clone(), conftest_content);
3799
3800 let test_content = r#"
3802from mymodule import foo
3803
3804def test_with_import():
3805 result = foo.something()
3806 assert result == "value"
3807"#;
3808 let test_path = PathBuf::from("/tmp/test_example.py");
3809 db.analyze_file(test_path.clone(), test_content);
3810
3811 let undeclared = db.get_undeclared_fixtures(&test_path);
3813
3814 assert_eq!(
3815 undeclared.len(),
3816 0,
3817 "Should not detect undeclared fixture when name is imported"
3818 );
3819}
3820
3821#[test]
3822fn test_warn_for_fixture_used_directly() {
3823 let db = FixtureDatabase::new();
3826
3827 let test_content = r#"
3828import pytest
3829
3830@pytest.fixture
3831def foo():
3832 return "fixture"
3833
3834def test_using_fixture_directly():
3835 # This is an error - fixtures must be declared as parameters
3836 result = foo.something()
3837 assert result == "value"
3838"#;
3839 let test_path = PathBuf::from("/tmp/test_example.py");
3840 db.analyze_file(test_path.clone(), test_content);
3841
3842 let undeclared = db.get_undeclared_fixtures(&test_path);
3844
3845 assert_eq!(
3846 undeclared.len(),
3847 1,
3848 "Should detect fixture used directly without parameter declaration"
3849 );
3850 assert_eq!(undeclared[0].name, "foo");
3851 assert_eq!(undeclared[0].function_name, "test_using_fixture_directly");
3852}
3853
3854#[test]
3855fn test_no_false_positive_for_module_level_assignment() {
3856 let db = FixtureDatabase::new();
3858
3859 let conftest_content = r#"
3861import pytest
3862
3863@pytest.fixture
3864def foo():
3865 return "fixture"
3866"#;
3867 let conftest_path = PathBuf::from("/tmp/conftest.py");
3868 db.analyze_file(conftest_path.clone(), conftest_content);
3869
3870 let test_content = r#"
3872# Module-level assignment
3873foo = SomeClass()
3874
3875def test_with_module_var():
3876 result = foo.method()
3877 assert result == "value"
3878"#;
3879 let test_path = PathBuf::from("/tmp/test_example.py");
3880 db.analyze_file(test_path.clone(), test_content);
3881
3882 let undeclared = db.get_undeclared_fixtures(&test_path);
3884
3885 assert_eq!(
3886 undeclared.len(),
3887 0,
3888 "Should not detect undeclared fixture when name is assigned at module level"
3889 );
3890}
3891
3892#[test]
3893fn test_no_false_positive_for_function_definition() {
3894 let db = FixtureDatabase::new();
3896
3897 let conftest_content = r#"
3899import pytest
3900
3901@pytest.fixture
3902def foo():
3903 return "fixture"
3904"#;
3905 let conftest_path = PathBuf::from("/tmp/conftest.py");
3906 db.analyze_file(conftest_path.clone(), conftest_content);
3907
3908 let test_content = r#"
3910def foo():
3911 return "not a fixture"
3912
3913def test_with_function():
3914 result = foo()
3915 assert result == "not a fixture"
3916"#;
3917 let test_path = PathBuf::from("/tmp/test_example.py");
3918 db.analyze_file(test_path.clone(), test_content);
3919
3920 let undeclared = db.get_undeclared_fixtures(&test_path);
3922
3923 assert_eq!(
3924 undeclared.len(),
3925 0,
3926 "Should not detect undeclared fixture when name is a regular function"
3927 );
3928}
3929
3930#[test]
3931fn test_no_false_positive_for_class_definition() {
3932 let db = FixtureDatabase::new();
3934
3935 let conftest_content = r#"
3937import pytest
3938
3939@pytest.fixture
3940def MyClass():
3941 return "fixture"
3942"#;
3943 let conftest_path = PathBuf::from("/tmp/conftest.py");
3944 db.analyze_file(conftest_path.clone(), conftest_content);
3945
3946 let test_content = r#"
3948class MyClass:
3949 pass
3950
3951def test_with_class():
3952 obj = MyClass()
3953 assert obj is not None
3954"#;
3955 let test_path = PathBuf::from("/tmp/test_example.py");
3956 db.analyze_file(test_path.clone(), test_content);
3957
3958 let undeclared = db.get_undeclared_fixtures(&test_path);
3960
3961 assert_eq!(
3962 undeclared.len(),
3963 0,
3964 "Should not detect undeclared fixture when name is a class"
3965 );
3966}
3967
3968#[test]
3969fn test_line_aware_local_variable_scope() {
3970 let db = FixtureDatabase::new();
3972
3973 let conftest_content = r#"
3975import pytest
3976
3977@pytest.fixture
3978def http_client():
3979 return "MockClient"
3980"#;
3981 let conftest_path = PathBuf::from("/tmp/conftest.py");
3982 db.analyze_file(conftest_path.clone(), conftest_content);
3983
3984 let test_content = r#"async def test_example():
3986 # Line 1: http_client should be flagged (not yet assigned)
3987 result = await http_client.get("/api")
3988 # Line 3: Now we assign http_client locally
3989 http_client = "local"
3990 # Line 5: http_client should NOT be flagged (local var now)
3991 result2 = await http_client.get("/api2")
3992"#;
3993 let test_path = PathBuf::from("/tmp/test_example.py");
3994 db.analyze_file(test_path.clone(), test_content);
3995
3996 let undeclared = db.get_undeclared_fixtures(&test_path);
3998
3999 assert_eq!(
4002 undeclared.len(),
4003 1,
4004 "Should detect http_client only before local assignment"
4005 );
4006 assert_eq!(undeclared[0].name, "http_client");
4007 assert_eq!(
4009 undeclared[0].line, 3,
4010 "Should flag usage on line 3 (before assignment on line 5)"
4011 );
4012}
4013
4014#[test]
4015fn test_same_line_assignment_and_usage() {
4016 let db = FixtureDatabase::new();
4018
4019 let conftest_content = r#"import pytest
4020
4021@pytest.fixture
4022def http_client():
4023 return "parent"
4024"#;
4025 let conftest_path = PathBuf::from("/tmp/conftest.py");
4026 db.analyze_file(conftest_path.clone(), conftest_content);
4027
4028 let test_content = r#"async def test_example():
4029 # This references the fixture on the RHS, then assigns to local var
4030 http_client = await http_client.get("/api")
4031"#;
4032 let test_path = PathBuf::from("/tmp/test_example.py");
4033 db.analyze_file(test_path.clone(), test_content);
4034
4035 let undeclared = db.get_undeclared_fixtures(&test_path);
4036
4037 assert_eq!(undeclared.len(), 1);
4039 assert_eq!(undeclared[0].name, "http_client");
4040 assert_eq!(undeclared[0].line, 3);
4041}
4042
4043#[test]
4044fn test_no_false_positive_for_later_assignment() {
4045 let db = FixtureDatabase::new();
4048
4049 let conftest_content = r#"import pytest
4050
4051@pytest.fixture
4052def http_client():
4053 return "fixture"
4054"#;
4055 let conftest_path = PathBuf::from("/tmp/conftest.py");
4056 db.analyze_file(conftest_path.clone(), conftest_content);
4057
4058 let test_content = r#"async def test_example():
4061 result = await http_client.get("/api") # Should be flagged
4062 # Now assign locally
4063 http_client = "local"
4064 # This should NOT be flagged because variable is now assigned
4065 result2 = http_client
4066"#;
4067 let test_path = PathBuf::from("/tmp/test_example.py");
4068 db.analyze_file(test_path.clone(), test_content);
4069
4070 let undeclared = db.get_undeclared_fixtures(&test_path);
4071
4072 assert_eq!(
4074 undeclared.len(),
4075 1,
4076 "Should detect exactly one undeclared fixture"
4077 );
4078 assert_eq!(undeclared[0].name, "http_client");
4079 assert_eq!(
4080 undeclared[0].line, 2,
4081 "Should flag usage on line 2 before assignment on line 4"
4082 );
4083}
4084
4085#[test]
4086fn test_fixture_resolution_priority_deterministic() {
4087 let db = FixtureDatabase::new();
4090
4091 let root_content = r#"
4096import pytest
4097
4098@pytest.fixture
4099def db():
4100 return "root_db"
4101"#;
4102 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4103 db.analyze_file(root_conftest.clone(), root_content);
4104
4105 let unrelated_content = r#"
4107import pytest
4108
4109@pytest.fixture
4110def db():
4111 return "unrelated_db"
4112"#;
4113 let unrelated_conftest = PathBuf::from("/tmp/other/conftest.py");
4114 db.analyze_file(unrelated_conftest.clone(), unrelated_content);
4115
4116 let app_content = r#"
4118import pytest
4119
4120@pytest.fixture
4121def db():
4122 return "app_db"
4123"#;
4124 let app_conftest = PathBuf::from("/tmp/project/app/conftest.py");
4125 db.analyze_file(app_conftest.clone(), app_content);
4126
4127 let tests_content = r#"
4129import pytest
4130
4131@pytest.fixture
4132def db():
4133 return "tests_db"
4134"#;
4135 let tests_conftest = PathBuf::from("/tmp/project/app/tests/conftest.py");
4136 db.analyze_file(tests_conftest.clone(), tests_content);
4137
4138 let test_content = r#"
4140def test_database(db):
4141 assert db is not None
4142"#;
4143 let test_path = PathBuf::from("/tmp/project/app/tests/test_foo.py");
4144 db.analyze_file(test_path.clone(), test_content);
4145
4146 for iteration in 0..10 {
4148 let result = db.find_fixture_definition(&test_path, 1, 18); assert!(
4151 result.is_some(),
4152 "Iteration {}: Should find a fixture definition",
4153 iteration
4154 );
4155
4156 let def = result.unwrap();
4157 assert_eq!(
4158 def.name, "db",
4159 "Iteration {}: Should find 'db' fixture",
4160 iteration
4161 );
4162
4163 assert_eq!(
4165 def.file_path, tests_conftest,
4166 "Iteration {}: Should consistently resolve to closest conftest.py at {:?}, but got {:?}",
4167 iteration,
4168 tests_conftest,
4169 def.file_path
4170 );
4171 }
4172}
4173
4174#[test]
4175fn test_fixture_resolution_prefers_parent_over_unrelated() {
4176 let db = FixtureDatabase::new();
4179
4180 let unrelated_content = r#"
4182import pytest
4183
4184@pytest.fixture
4185def custom_fixture():
4186 return "unrelated"
4187"#;
4188 let unrelated_conftest = PathBuf::from("/tmp/other_project/conftest.py");
4189 db.analyze_file(unrelated_conftest.clone(), unrelated_content);
4190
4191 let third_party_content = r#"
4193import pytest
4194
4195@pytest.fixture
4196def custom_fixture():
4197 return "third_party"
4198"#;
4199 let third_party_path =
4200 PathBuf::from("/tmp/.venv/lib/python3.11/site-packages/pytest_custom/plugin.py");
4201 db.analyze_file(third_party_path.clone(), third_party_content);
4202
4203 let test_content = r#"
4205def test_custom(custom_fixture):
4206 assert custom_fixture is not None
4207"#;
4208 let test_path = PathBuf::from("/tmp/my_project/test_foo.py");
4209 db.analyze_file(test_path.clone(), test_content);
4210
4211 let result = db.find_fixture_definition(&test_path, 1, 16);
4213 assert!(result.is_some());
4214 let def = result.unwrap();
4215
4216 assert_eq!(
4218 def.file_path, third_party_path,
4219 "Should prefer third-party fixture from site-packages over unrelated conftest.py"
4220 );
4221}
4222
4223#[test]
4224fn test_fixture_resolution_hierarchy_over_third_party() {
4225 let db = FixtureDatabase::new();
4227
4228 let third_party_content = r#"
4230import pytest
4231
4232@pytest.fixture
4233def mocker():
4234 return "third_party_mocker"
4235"#;
4236 let third_party_path =
4237 PathBuf::from("/tmp/project/.venv/lib/python3.11/site-packages/pytest_mock/plugin.py");
4238 db.analyze_file(third_party_path.clone(), third_party_content);
4239
4240 let local_content = r#"
4242import pytest
4243
4244@pytest.fixture
4245def mocker():
4246 return "local_mocker"
4247"#;
4248 let local_conftest = PathBuf::from("/tmp/project/conftest.py");
4249 db.analyze_file(local_conftest.clone(), local_content);
4250
4251 let test_content = r#"
4253def test_mocking(mocker):
4254 assert mocker is not None
4255"#;
4256 let test_path = PathBuf::from("/tmp/project/test_foo.py");
4257 db.analyze_file(test_path.clone(), test_content);
4258
4259 let result = db.find_fixture_definition(&test_path, 1, 17);
4261 assert!(result.is_some());
4262 let def = result.unwrap();
4263
4264 assert_eq!(
4265 def.file_path, local_conftest,
4266 "Should prefer local conftest.py fixture over third-party fixture"
4267 );
4268}
4269
4270#[test]
4271fn test_fixture_resolution_with_relative_paths() {
4272 let db = FixtureDatabase::new();
4275
4276 let conftest_content = r#"
4278import pytest
4279
4280@pytest.fixture
4281def shared():
4282 return "conftest"
4283"#;
4284 let conftest_abs = PathBuf::from("/tmp/project/tests/conftest.py");
4285 db.analyze_file(conftest_abs.clone(), conftest_content);
4286
4287 let test_content = r#"
4289def test_example(shared):
4290 assert shared == "conftest"
4291"#;
4292 let test_abs = PathBuf::from("/tmp/project/tests/test_foo.py");
4293 db.analyze_file(test_abs.clone(), test_content);
4294
4295 let result = db.find_fixture_definition(&test_abs, 1, 17);
4297 assert!(result.is_some(), "Should find fixture with absolute paths");
4298 let def = result.unwrap();
4299 assert_eq!(def.file_path, conftest_abs, "Should resolve to conftest.py");
4300}
4301
4302#[test]
4303fn test_fixture_resolution_deep_hierarchy() {
4304 let db = FixtureDatabase::new();
4306
4307 let root_content = r#"
4309import pytest
4310
4311@pytest.fixture
4312def db():
4313 return "root"
4314"#;
4315 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4316 db.analyze_file(root_conftest.clone(), root_content);
4317
4318 let level1_content = r#"
4320import pytest
4321
4322@pytest.fixture
4323def db():
4324 return "level1"
4325"#;
4326 let level1_conftest = PathBuf::from("/tmp/project/src/conftest.py");
4327 db.analyze_file(level1_conftest.clone(), level1_content);
4328
4329 let level2_content = r#"
4331import pytest
4332
4333@pytest.fixture
4334def db():
4335 return "level2"
4336"#;
4337 let level2_conftest = PathBuf::from("/tmp/project/src/app/conftest.py");
4338 db.analyze_file(level2_conftest.clone(), level2_content);
4339
4340 let level3_content = r#"
4342import pytest
4343
4344@pytest.fixture
4345def db():
4346 return "level3"
4347"#;
4348 let level3_conftest = PathBuf::from("/tmp/project/src/app/tests/conftest.py");
4349 db.analyze_file(level3_conftest.clone(), level3_content);
4350
4351 let test_l3_content = r#"
4353def test_db(db):
4354 assert db == "level3"
4355"#;
4356 let test_l3 = PathBuf::from("/tmp/project/src/app/tests/test_foo.py");
4357 db.analyze_file(test_l3.clone(), test_l3_content);
4358
4359 let result_l3 = db.find_fixture_definition(&test_l3, 1, 12);
4360 assert!(result_l3.is_some());
4361 assert_eq!(
4362 result_l3.unwrap().file_path,
4363 level3_conftest,
4364 "Test at level 3 should use level 3 fixture"
4365 );
4366
4367 let test_l2_content = r#"
4369def test_db(db):
4370 assert db == "level2"
4371"#;
4372 let test_l2 = PathBuf::from("/tmp/project/src/app/test_bar.py");
4373 db.analyze_file(test_l2.clone(), test_l2_content);
4374
4375 let result_l2 = db.find_fixture_definition(&test_l2, 1, 12);
4376 assert!(result_l2.is_some());
4377 assert_eq!(
4378 result_l2.unwrap().file_path,
4379 level2_conftest,
4380 "Test at level 2 should use level 2 fixture"
4381 );
4382
4383 let test_l1_content = r#"
4385def test_db(db):
4386 assert db == "level1"
4387"#;
4388 let test_l1 = PathBuf::from("/tmp/project/src/test_baz.py");
4389 db.analyze_file(test_l1.clone(), test_l1_content);
4390
4391 let result_l1 = db.find_fixture_definition(&test_l1, 1, 12);
4392 assert!(result_l1.is_some());
4393 assert_eq!(
4394 result_l1.unwrap().file_path,
4395 level1_conftest,
4396 "Test at level 1 should use level 1 fixture"
4397 );
4398
4399 let test_root_content = r#"
4401def test_db(db):
4402 assert db == "root"
4403"#;
4404 let test_root = PathBuf::from("/tmp/project/test_root.py");
4405 db.analyze_file(test_root.clone(), test_root_content);
4406
4407 let result_root = db.find_fixture_definition(&test_root, 1, 12);
4408 assert!(result_root.is_some());
4409 assert_eq!(
4410 result_root.unwrap().file_path,
4411 root_conftest,
4412 "Test at root should use root fixture"
4413 );
4414}
4415
4416#[test]
4417fn test_fixture_resolution_sibling_directories() {
4418 let db = FixtureDatabase::new();
4420
4421 let root_content = r#"
4423import pytest
4424
4425@pytest.fixture
4426def shared():
4427 return "root"
4428"#;
4429 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4430 db.analyze_file(root_conftest.clone(), root_content);
4431
4432 let module_a_content = r#"
4434import pytest
4435
4436@pytest.fixture
4437def module_specific():
4438 return "module_a"
4439"#;
4440 let module_a_conftest = PathBuf::from("/tmp/project/module_a/conftest.py");
4441 db.analyze_file(module_a_conftest.clone(), module_a_content);
4442
4443 let module_b_content = r#"
4445import pytest
4446
4447@pytest.fixture
4448def module_specific():
4449 return "module_b"
4450"#;
4451 let module_b_conftest = PathBuf::from("/tmp/project/module_b/conftest.py");
4452 db.analyze_file(module_b_conftest.clone(), module_b_content);
4453
4454 let test_a_content = r#"
4456def test_a(module_specific, shared):
4457 assert module_specific == "module_a"
4458 assert shared == "root"
4459"#;
4460 let test_a = PathBuf::from("/tmp/project/module_a/test_a.py");
4461 db.analyze_file(test_a.clone(), test_a_content);
4462
4463 let result_a = db.find_fixture_definition(&test_a, 1, 11);
4464 assert!(result_a.is_some());
4465 assert_eq!(
4466 result_a.unwrap().file_path,
4467 module_a_conftest,
4468 "Test in module_a should use module_a's fixture"
4469 );
4470
4471 let test_b_content = r#"
4473def test_b(module_specific, shared):
4474 assert module_specific == "module_b"
4475 assert shared == "root"
4476"#;
4477 let test_b = PathBuf::from("/tmp/project/module_b/test_b.py");
4478 db.analyze_file(test_b.clone(), test_b_content);
4479
4480 let result_b = db.find_fixture_definition(&test_b, 1, 11);
4481 assert!(result_b.is_some());
4482 assert_eq!(
4483 result_b.unwrap().file_path,
4484 module_b_conftest,
4485 "Test in module_b should use module_b's fixture"
4486 );
4487
4488 let result_a_shared = db.find_fixture_definition(&test_a, 1, 29);
4491 assert!(result_a_shared.is_some());
4492 assert_eq!(
4493 result_a_shared.unwrap().file_path,
4494 root_conftest,
4495 "Test in module_a should access root's shared fixture"
4496 );
4497
4498 let result_b_shared = db.find_fixture_definition(&test_b, 1, 29);
4499 assert!(result_b_shared.is_some());
4500 assert_eq!(
4501 result_b_shared.unwrap().file_path,
4502 root_conftest,
4503 "Test in module_b should access root's shared fixture"
4504 );
4505}
4506
4507#[test]
4508fn test_fixture_resolution_multiple_unrelated_branches_is_deterministic() {
4509 let db = FixtureDatabase::new();
4512
4513 let branch_a_content = r#"
4515import pytest
4516
4517@pytest.fixture
4518def common_fixture():
4519 return "branch_a"
4520"#;
4521 let branch_a_conftest = PathBuf::from("/tmp/projects/project_a/conftest.py");
4522 db.analyze_file(branch_a_conftest.clone(), branch_a_content);
4523
4524 let branch_b_content = r#"
4525import pytest
4526
4527@pytest.fixture
4528def common_fixture():
4529 return "branch_b"
4530"#;
4531 let branch_b_conftest = PathBuf::from("/tmp/projects/project_b/conftest.py");
4532 db.analyze_file(branch_b_conftest.clone(), branch_b_content);
4533
4534 let branch_c_content = r#"
4535import pytest
4536
4537@pytest.fixture
4538def common_fixture():
4539 return "branch_c"
4540"#;
4541 let branch_c_conftest = PathBuf::from("/tmp/projects/project_c/conftest.py");
4542 db.analyze_file(branch_c_conftest.clone(), branch_c_content);
4543
4544 let test_content = r#"
4546def test_something(common_fixture):
4547 assert common_fixture is not None
4548"#;
4549 let test_path = PathBuf::from("/tmp/unrelated/test_foo.py");
4550 db.analyze_file(test_path.clone(), test_content);
4551
4552 let mut results = Vec::new();
4554 for _ in 0..20 {
4555 let result = db.find_fixture_definition(&test_path, 1, 19);
4556 assert!(result.is_some(), "Should find a fixture");
4557 results.push(result.unwrap().file_path.clone());
4558 }
4559
4560 let first_result = &results[0];
4562 for (i, result) in results.iter().enumerate() {
4563 assert_eq!(
4564 result, first_result,
4565 "Iteration {}: fixture resolution should be deterministic, expected {:?} but got {:?}",
4566 i, first_result, result
4567 );
4568 }
4569}
4570
4571#[test]
4572fn test_fixture_resolution_conftest_at_various_depths() {
4573 let db = FixtureDatabase::new();
4575
4576 let deep_content = r#"
4578import pytest
4579
4580@pytest.fixture
4581def fixture_a():
4582 return "deep"
4583
4584@pytest.fixture
4585def fixture_b():
4586 return "deep"
4587"#;
4588 let deep_conftest = PathBuf::from("/tmp/project/src/module/tests/integration/conftest.py");
4589 db.analyze_file(deep_conftest.clone(), deep_content);
4590
4591 let mid_content = r#"
4593import pytest
4594
4595@pytest.fixture
4596def fixture_a():
4597 return "mid"
4598"#;
4599 let mid_conftest = PathBuf::from("/tmp/project/src/module/conftest.py");
4600 db.analyze_file(mid_conftest.clone(), mid_content);
4601
4602 let root_content = r#"
4604import pytest
4605
4606@pytest.fixture
4607def fixture_c():
4608 return "root"
4609"#;
4610 let root_conftest = PathBuf::from("/tmp/project/conftest.py");
4611 db.analyze_file(root_conftest.clone(), root_content);
4612
4613 let test_content = r#"
4615def test_all(fixture_a, fixture_b, fixture_c):
4616 assert fixture_a == "deep"
4617 assert fixture_b == "deep"
4618 assert fixture_c == "root"
4619"#;
4620 let test_path = PathBuf::from("/tmp/project/src/module/tests/integration/test_foo.py");
4621 db.analyze_file(test_path.clone(), test_content);
4622
4623 let result_a = db.find_fixture_definition(&test_path, 1, 13);
4625 assert!(result_a.is_some());
4626 assert_eq!(
4627 result_a.unwrap().file_path,
4628 deep_conftest,
4629 "fixture_a should resolve to closest conftest (deep)"
4630 );
4631
4632 let result_b = db.find_fixture_definition(&test_path, 1, 24);
4634 assert!(result_b.is_some());
4635 assert_eq!(
4636 result_b.unwrap().file_path,
4637 deep_conftest,
4638 "fixture_b should resolve to deep conftest"
4639 );
4640
4641 let result_c = db.find_fixture_definition(&test_path, 1, 35);
4643 assert!(result_c.is_some());
4644 assert_eq!(
4645 result_c.unwrap().file_path,
4646 root_conftest,
4647 "fixture_c should resolve to root conftest"
4648 );
4649
4650 let test_mid_content = r#"
4652def test_mid(fixture_a, fixture_c):
4653 assert fixture_a == "mid"
4654 assert fixture_c == "root"
4655"#;
4656 let test_mid_path = PathBuf::from("/tmp/project/src/module/test_bar.py");
4657 db.analyze_file(test_mid_path.clone(), test_mid_content);
4658
4659 let result_a_mid = db.find_fixture_definition(&test_mid_path, 1, 13);
4661 assert!(result_a_mid.is_some());
4662 assert_eq!(
4663 result_a_mid.unwrap().file_path,
4664 mid_conftest,
4665 "fixture_a from mid-level test should resolve to mid conftest"
4666 );
4667}
4668
4669#[test]
4670fn test_get_available_fixtures_same_file() {
4671 let db = FixtureDatabase::new();
4672
4673 let test_content = r#"
4674import pytest
4675
4676@pytest.fixture
4677def fixture_a():
4678 return "a"
4679
4680@pytest.fixture
4681def fixture_b():
4682 return "b"
4683
4684def test_something():
4685 pass
4686"#;
4687 let test_path = PathBuf::from("/tmp/test/test_example.py");
4688 db.analyze_file(test_path.clone(), test_content);
4689
4690 let available = db.get_available_fixtures(&test_path);
4691
4692 assert_eq!(available.len(), 2, "Should find 2 fixtures in same file");
4693
4694 let names: Vec<_> = available.iter().map(|f| f.name.as_str()).collect();
4695 assert!(names.contains(&"fixture_a"));
4696 assert!(names.contains(&"fixture_b"));
4697}
4698
4699#[test]
4700fn test_get_available_fixtures_conftest_hierarchy() {
4701 let db = FixtureDatabase::new();
4702
4703 let root_conftest = r#"
4705import pytest
4706
4707@pytest.fixture
4708def root_fixture():
4709 return "root"
4710"#;
4711 let root_path = PathBuf::from("/tmp/test/conftest.py");
4712 db.analyze_file(root_path.clone(), root_conftest);
4713
4714 let sub_conftest = r#"
4716import pytest
4717
4718@pytest.fixture
4719def sub_fixture():
4720 return "sub"
4721"#;
4722 let sub_path = PathBuf::from("/tmp/test/subdir/conftest.py");
4723 db.analyze_file(sub_path.clone(), sub_conftest);
4724
4725 let test_content = r#"
4727def test_something():
4728 pass
4729"#;
4730 let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
4731 db.analyze_file(test_path.clone(), test_content);
4732
4733 let available = db.get_available_fixtures(&test_path);
4734
4735 assert_eq!(
4736 available.len(),
4737 2,
4738 "Should find fixtures from both conftest files"
4739 );
4740
4741 let names: Vec<_> = available.iter().map(|f| f.name.as_str()).collect();
4742 assert!(names.contains(&"root_fixture"));
4743 assert!(names.contains(&"sub_fixture"));
4744}
4745
4746#[test]
4747fn test_get_available_fixtures_no_duplicates() {
4748 let db = FixtureDatabase::new();
4749
4750 let root_conftest = r#"
4752import pytest
4753
4754@pytest.fixture
4755def shared_fixture():
4756 return "root"
4757"#;
4758 let root_path = PathBuf::from("/tmp/test/conftest.py");
4759 db.analyze_file(root_path.clone(), root_conftest);
4760
4761 let sub_conftest = r#"
4763import pytest
4764
4765@pytest.fixture
4766def shared_fixture():
4767 return "sub"
4768"#;
4769 let sub_path = PathBuf::from("/tmp/test/subdir/conftest.py");
4770 db.analyze_file(sub_path.clone(), sub_conftest);
4771
4772 let test_content = r#"
4774def test_something():
4775 pass
4776"#;
4777 let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
4778 db.analyze_file(test_path.clone(), test_content);
4779
4780 let available = db.get_available_fixtures(&test_path);
4781
4782 let shared_count = available
4784 .iter()
4785 .filter(|f| f.name == "shared_fixture")
4786 .count();
4787 assert_eq!(shared_count, 1, "Should only include shared_fixture once");
4788
4789 let shared_fixture = available
4791 .iter()
4792 .find(|f| f.name == "shared_fixture")
4793 .unwrap();
4794 assert_eq!(shared_fixture.file_path, sub_path);
4795}
4796
4797#[test]
4798fn test_is_inside_function_in_test() {
4799 let db = FixtureDatabase::new();
4800
4801 let test_content = r#"
4802import pytest
4803
4804def test_example(fixture_a, fixture_b):
4805 result = fixture_a + fixture_b
4806 assert result == "ab"
4807"#;
4808 let test_path = PathBuf::from("/tmp/test/test_example.py");
4809 db.analyze_file(test_path.clone(), test_content);
4810
4811 let result = db.is_inside_function(&test_path, 3, 10);
4813 assert!(result.is_some());
4814
4815 let (func_name, is_fixture, params) = result.unwrap();
4816 assert_eq!(func_name, "test_example");
4817 assert!(!is_fixture);
4818 assert_eq!(params, vec!["fixture_a", "fixture_b"]);
4819
4820 let result = db.is_inside_function(&test_path, 4, 10);
4822 assert!(result.is_some());
4823
4824 let (func_name, is_fixture, _) = result.unwrap();
4825 assert_eq!(func_name, "test_example");
4826 assert!(!is_fixture);
4827}
4828
4829#[test]
4830fn test_is_inside_function_in_fixture() {
4831 let db = FixtureDatabase::new();
4832
4833 let test_content = r#"
4834import pytest
4835
4836@pytest.fixture
4837def my_fixture(other_fixture):
4838 return other_fixture + "_modified"
4839"#;
4840 let test_path = PathBuf::from("/tmp/test/conftest.py");
4841 db.analyze_file(test_path.clone(), test_content);
4842
4843 let result = db.is_inside_function(&test_path, 4, 10);
4845 assert!(result.is_some());
4846
4847 let (func_name, is_fixture, params) = result.unwrap();
4848 assert_eq!(func_name, "my_fixture");
4849 assert!(is_fixture);
4850 assert_eq!(params, vec!["other_fixture"]);
4851
4852 let result = db.is_inside_function(&test_path, 5, 10);
4854 assert!(result.is_some());
4855
4856 let (func_name, is_fixture, _) = result.unwrap();
4857 assert_eq!(func_name, "my_fixture");
4858 assert!(is_fixture);
4859}
4860
4861#[test]
4862fn test_is_inside_function_outside() {
4863 let db = FixtureDatabase::new();
4864
4865 let test_content = r#"
4866import pytest
4867
4868@pytest.fixture
4869def my_fixture():
4870 return "value"
4871
4872def test_example(my_fixture):
4873 assert my_fixture == "value"
4874
4875# This is a comment outside any function
4876"#;
4877 let test_path = PathBuf::from("/tmp/test/test_example.py");
4878 db.analyze_file(test_path.clone(), test_content);
4879
4880 let result = db.is_inside_function(&test_path, 0, 0);
4882 assert!(
4883 result.is_none(),
4884 "Should not be inside a function on import line"
4885 );
4886
4887 let result = db.is_inside_function(&test_path, 9, 0);
4889 assert!(
4890 result.is_none(),
4891 "Should not be inside a function on comment line"
4892 );
4893}
4894
4895#[test]
4896fn test_is_inside_function_non_test() {
4897 let db = FixtureDatabase::new();
4898
4899 let test_content = r#"
4900import pytest
4901
4902def helper_function():
4903 return "helper"
4904
4905def test_example():
4906 result = helper_function()
4907 assert result == "helper"
4908"#;
4909 let test_path = PathBuf::from("/tmp/test/test_example.py");
4910 db.analyze_file(test_path.clone(), test_content);
4911
4912 let result = db.is_inside_function(&test_path, 3, 10);
4914 assert!(
4915 result.is_none(),
4916 "Should not return non-test, non-fixture functions"
4917 );
4918
4919 let result = db.is_inside_function(&test_path, 6, 10);
4921 assert!(result.is_some(), "Should return test functions");
4922
4923 let (func_name, is_fixture, _) = result.unwrap();
4924 assert_eq!(func_name, "test_example");
4925 assert!(!is_fixture);
4926}
4927
4928#[test]
4929fn test_is_inside_async_function() {
4930 let db = FixtureDatabase::new();
4931
4932 let test_content = r#"
4933import pytest
4934
4935@pytest.fixture
4936async def async_fixture():
4937 return "async_value"
4938
4939async def test_async_example(async_fixture):
4940 assert async_fixture == "async_value"
4941"#;
4942 let test_path = PathBuf::from("/tmp/test/test_async.py");
4943 db.analyze_file(test_path.clone(), test_content);
4944
4945 let result = db.is_inside_function(&test_path, 4, 10);
4947 assert!(result.is_some());
4948
4949 let (func_name, is_fixture, _) = result.unwrap();
4950 assert_eq!(func_name, "async_fixture");
4951 assert!(is_fixture);
4952
4953 let result = db.is_inside_function(&test_path, 7, 10);
4955 assert!(result.is_some());
4956
4957 let (func_name, is_fixture, params) = result.unwrap();
4958 assert_eq!(func_name, "test_async_example");
4959 assert!(!is_fixture);
4960 assert_eq!(params, vec!["async_fixture"]);
4961}