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, 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 debug!("Analyzing file: {:?}", file_path);
266
267 self.file_cache
269 .insert(file_path.clone(), content.to_string());
270
271 let parsed = match parse(content, Mode::Module, "") {
273 Ok(ast) => ast,
274 Err(e) => {
275 warn!("Failed to parse {:?}: {:?}", file_path, e);
276 return;
277 }
278 };
279
280 self.usages.remove(&file_path);
282
283 self.undeclared_fixtures.remove(&file_path);
285
286 self.imports.remove(&file_path);
288
289 for mut entry in self.definitions.iter_mut() {
292 entry.value_mut().retain(|def| def.file_path != file_path);
293 }
294 self.definitions.retain(|_, defs| !defs.is_empty());
296
297 let is_conftest = file_path
299 .file_name()
300 .map(|n| n == "conftest.py")
301 .unwrap_or(false);
302 debug!("is_conftest: {}", is_conftest);
303
304 if let rustpython_parser::ast::Mod::Module(module) = parsed {
306 debug!("Module has {} statements", module.body.len());
307
308 let mut module_level_names = std::collections::HashSet::new();
310 for stmt in &module.body {
311 self.collect_module_level_names(stmt, &mut module_level_names);
312 }
313 self.imports.insert(file_path.clone(), module_level_names);
314
315 for stmt in &module.body {
317 self.visit_stmt(stmt, &file_path, is_conftest, content);
318 }
319 }
320
321 debug!("Analysis complete for {:?}", file_path);
322 }
323
324 fn visit_stmt(&self, stmt: &Stmt, file_path: &PathBuf, _is_conftest: bool, content: &str) {
325 if let Stmt::Assign(assign) = stmt {
327 self.visit_assignment_fixture(assign, file_path, content);
328 }
329
330 let (func_name, decorator_list, args, range, body) = match stmt {
332 Stmt::FunctionDef(func_def) => (
333 func_def.name.as_str(),
334 &func_def.decorator_list,
335 &func_def.args,
336 func_def.range,
337 &func_def.body,
338 ),
339 Stmt::AsyncFunctionDef(func_def) => (
340 func_def.name.as_str(),
341 &func_def.decorator_list,
342 &func_def.args,
343 func_def.range,
344 &func_def.body,
345 ),
346 _ => return,
347 };
348
349 debug!("Found function: {}", func_name);
350
351 debug!(
353 "Function {} has {} decorators",
354 func_name,
355 decorator_list.len()
356 );
357 let is_fixture = decorator_list.iter().any(|dec| {
358 let result = Self::is_fixture_decorator(dec);
359 if result {
360 debug!(" Decorator matched as fixture!");
361 }
362 result
363 });
364
365 if is_fixture {
366 let line = self.get_line_from_offset(range.start().to_usize(), content);
368
369 let docstring = self.extract_docstring(body);
371
372 info!(
373 "Found fixture definition: {} at {:?}:{}",
374 func_name, file_path, line
375 );
376 if let Some(ref doc) = docstring {
377 debug!(" Docstring: {}", doc);
378 }
379
380 let definition = FixtureDefinition {
381 name: func_name.to_string(),
382 file_path: file_path.clone(),
383 line,
384 docstring,
385 };
386
387 self.definitions
388 .entry(func_name.to_string())
389 .or_default()
390 .push(definition);
391
392 let mut declared_params: std::collections::HashSet<String> =
394 std::collections::HashSet::new();
395 declared_params.insert("self".to_string());
396 declared_params.insert("request".to_string());
397 declared_params.insert(func_name.to_string()); for arg in &args.args {
400 let arg_name = arg.def.arg.as_str();
401 declared_params.insert(arg_name.to_string());
402
403 if arg_name != "self" && arg_name != "request" {
404 let arg_line =
407 self.get_line_from_offset(arg.def.range.start().to_usize(), content);
408 let start_char = self
409 .get_char_position_from_offset(arg.def.range.start().to_usize(), content);
410 let end_char =
411 self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
412
413 info!(
414 "Found fixture dependency: {} at {:?}:{}:{}",
415 arg_name, file_path, arg_line, start_char
416 );
417
418 let usage = FixtureUsage {
419 name: arg_name.to_string(),
420 file_path: file_path.clone(),
421 line: arg_line, start_char,
423 end_char,
424 };
425
426 self.usages
427 .entry(file_path.clone())
428 .or_default()
429 .push(usage);
430 }
431 }
432
433 let function_line = self.get_line_from_offset(range.start().to_usize(), content);
435 self.scan_function_body_for_undeclared_fixtures(
436 body,
437 file_path,
438 content,
439 &declared_params,
440 func_name,
441 function_line,
442 );
443 }
444
445 let is_test = func_name.starts_with("test_");
447
448 if is_test {
449 debug!("Found test function: {}", func_name);
450
451 let mut declared_params: std::collections::HashSet<String> =
453 std::collections::HashSet::new();
454 declared_params.insert("self".to_string());
455 declared_params.insert("request".to_string()); for arg in &args.args {
459 let arg_name = arg.def.arg.as_str();
460 declared_params.insert(arg_name.to_string());
461
462 if arg_name != "self" {
463 let arg_offset = arg.def.range.start().to_usize();
467 let arg_line = self.get_line_from_offset(arg_offset, content);
468 let start_char = self.get_char_position_from_offset(arg_offset, content);
469 let end_char =
470 self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
471
472 debug!(
473 "Parameter {} at offset {}, calculated line {}, char {}",
474 arg_name, arg_offset, arg_line, start_char
475 );
476 info!(
477 "Found fixture usage: {} at {:?}:{}:{}",
478 arg_name, file_path, arg_line, start_char
479 );
480
481 let usage = FixtureUsage {
482 name: arg_name.to_string(),
483 file_path: file_path.clone(),
484 line: arg_line, start_char,
486 end_char,
487 };
488
489 self.usages
491 .entry(file_path.clone())
492 .or_default()
493 .push(usage);
494 }
495 }
496
497 let function_line = self.get_line_from_offset(range.start().to_usize(), content);
499 self.scan_function_body_for_undeclared_fixtures(
500 body,
501 file_path,
502 content,
503 &declared_params,
504 func_name,
505 function_line,
506 );
507 }
508 }
509
510 fn visit_assignment_fixture(
511 &self,
512 assign: &rustpython_parser::ast::StmtAssign,
513 file_path: &PathBuf,
514 content: &str,
515 ) {
516 if let Expr::Call(outer_call) = &*assign.value {
520 if let Expr::Call(inner_call) = &*outer_call.func {
522 if Self::is_fixture_decorator(&inner_call.func) {
523 for target in &assign.targets {
526 if let Expr::Name(name) = target {
527 let fixture_name = name.id.as_str();
528 let line =
529 self.get_line_from_offset(assign.range.start().to_usize(), content);
530
531 info!(
532 "Found fixture assignment: {} at {:?}:{}",
533 fixture_name, file_path, line
534 );
535
536 let definition = FixtureDefinition {
538 name: fixture_name.to_string(),
539 file_path: file_path.clone(),
540 line,
541 docstring: None,
542 };
543
544 self.definitions
545 .entry(fixture_name.to_string())
546 .or_default()
547 .push(definition);
548 }
549 }
550 }
551 }
552 }
553 }
554
555 fn is_fixture_decorator(expr: &Expr) -> bool {
556 match expr {
557 Expr::Name(name) => name.id.as_str() == "fixture",
558 Expr::Attribute(attr) => {
559 if let Expr::Name(value) = &*attr.value {
561 value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
562 } else {
563 false
564 }
565 }
566 Expr::Call(call) => {
567 Self::is_fixture_decorator(&call.func)
569 }
570 _ => false,
571 }
572 }
573
574 fn scan_function_body_for_undeclared_fixtures(
575 &self,
576 body: &[Stmt],
577 file_path: &PathBuf,
578 content: &str,
579 declared_params: &std::collections::HashSet<String>,
580 function_name: &str,
581 function_line: usize,
582 ) {
583 let mut local_vars = std::collections::HashMap::new();
585 self.collect_local_variables(body, content, &mut local_vars);
586
587 if let Some(imports) = self.imports.get(file_path) {
590 for import in imports.iter() {
591 local_vars.insert(import.clone(), 0);
592 }
593 }
594
595 for stmt in body {
597 self.visit_stmt_for_names(
598 stmt,
599 file_path,
600 content,
601 declared_params,
602 &local_vars,
603 function_name,
604 function_line,
605 );
606 }
607 }
608
609 fn collect_module_level_names(
610 &self,
611 stmt: &Stmt,
612 names: &mut std::collections::HashSet<String>,
613 ) {
614 match stmt {
615 Stmt::Import(import_stmt) => {
617 for alias in &import_stmt.names {
618 let name = alias.asname.as_ref().unwrap_or(&alias.name);
620 names.insert(name.to_string());
621 }
622 }
623 Stmt::ImportFrom(import_from) => {
624 for alias in &import_from.names {
625 let name = alias.asname.as_ref().unwrap_or(&alias.name);
627 names.insert(name.to_string());
628 }
629 }
630 Stmt::FunctionDef(func_def) => {
632 let is_fixture = func_def
634 .decorator_list
635 .iter()
636 .any(Self::is_fixture_decorator);
637 if !is_fixture {
638 names.insert(func_def.name.to_string());
639 }
640 }
641 Stmt::AsyncFunctionDef(func_def) => {
643 let is_fixture = func_def
644 .decorator_list
645 .iter()
646 .any(Self::is_fixture_decorator);
647 if !is_fixture {
648 names.insert(func_def.name.to_string());
649 }
650 }
651 Stmt::ClassDef(class_def) => {
653 names.insert(class_def.name.to_string());
654 }
655 Stmt::Assign(assign) => {
657 for target in &assign.targets {
658 self.collect_names_from_expr(target, names);
659 }
660 }
661 Stmt::AnnAssign(ann_assign) => {
662 self.collect_names_from_expr(&ann_assign.target, names);
663 }
664 _ => {}
665 }
666 }
667
668 fn collect_local_variables(
669 &self,
670 body: &[Stmt],
671 content: &str,
672 local_vars: &mut std::collections::HashMap<String, usize>,
673 ) {
674 for stmt in body {
675 match stmt {
676 Stmt::Assign(assign) => {
677 let line = self.get_line_from_offset(assign.range.start().to_usize(), content);
679 let mut temp_names = std::collections::HashSet::new();
680 for target in &assign.targets {
681 self.collect_names_from_expr(target, &mut temp_names);
682 }
683 for name in temp_names {
684 local_vars.insert(name, line);
685 }
686 }
687 Stmt::AnnAssign(ann_assign) => {
688 let line =
690 self.get_line_from_offset(ann_assign.range.start().to_usize(), content);
691 let mut temp_names = std::collections::HashSet::new();
692 self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
693 for name in temp_names {
694 local_vars.insert(name, line);
695 }
696 }
697 Stmt::AugAssign(aug_assign) => {
698 let line =
700 self.get_line_from_offset(aug_assign.range.start().to_usize(), content);
701 let mut temp_names = std::collections::HashSet::new();
702 self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
703 for name in temp_names {
704 local_vars.insert(name, line);
705 }
706 }
707 Stmt::For(for_stmt) => {
708 let line =
710 self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
711 let mut temp_names = std::collections::HashSet::new();
712 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
713 for name in temp_names {
714 local_vars.insert(name, line);
715 }
716 self.collect_local_variables(&for_stmt.body, content, local_vars);
718 }
719 Stmt::AsyncFor(for_stmt) => {
720 let line =
721 self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
722 let mut temp_names = std::collections::HashSet::new();
723 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
724 for name in temp_names {
725 local_vars.insert(name, line);
726 }
727 self.collect_local_variables(&for_stmt.body, content, local_vars);
728 }
729 Stmt::While(while_stmt) => {
730 self.collect_local_variables(&while_stmt.body, content, local_vars);
731 }
732 Stmt::If(if_stmt) => {
733 self.collect_local_variables(&if_stmt.body, content, local_vars);
734 self.collect_local_variables(&if_stmt.orelse, content, local_vars);
735 }
736 Stmt::With(with_stmt) => {
737 let line =
739 self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
740 for item in &with_stmt.items {
741 if let Some(ref optional_vars) = item.optional_vars {
742 let mut temp_names = std::collections::HashSet::new();
743 self.collect_names_from_expr(optional_vars, &mut temp_names);
744 for name in temp_names {
745 local_vars.insert(name, line);
746 }
747 }
748 }
749 self.collect_local_variables(&with_stmt.body, content, local_vars);
750 }
751 Stmt::AsyncWith(with_stmt) => {
752 let line =
753 self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
754 for item in &with_stmt.items {
755 if let Some(ref optional_vars) = item.optional_vars {
756 let mut temp_names = std::collections::HashSet::new();
757 self.collect_names_from_expr(optional_vars, &mut temp_names);
758 for name in temp_names {
759 local_vars.insert(name, line);
760 }
761 }
762 }
763 self.collect_local_variables(&with_stmt.body, content, local_vars);
764 }
765 Stmt::Try(try_stmt) => {
766 self.collect_local_variables(&try_stmt.body, content, local_vars);
767 self.collect_local_variables(&try_stmt.orelse, content, local_vars);
770 self.collect_local_variables(&try_stmt.finalbody, content, local_vars);
771 }
772 _ => {}
773 }
774 }
775 }
776
777 #[allow(clippy::only_used_in_recursion)]
778 fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
779 match expr {
780 Expr::Name(name) => {
781 names.insert(name.id.to_string());
782 }
783 Expr::Tuple(tuple) => {
784 for elt in &tuple.elts {
785 self.collect_names_from_expr(elt, names);
786 }
787 }
788 Expr::List(list) => {
789 for elt in &list.elts {
790 self.collect_names_from_expr(elt, names);
791 }
792 }
793 _ => {}
794 }
795 }
796
797 #[allow(clippy::too_many_arguments)]
798 fn visit_stmt_for_names(
799 &self,
800 stmt: &Stmt,
801 file_path: &PathBuf,
802 content: &str,
803 declared_params: &std::collections::HashSet<String>,
804 local_vars: &std::collections::HashMap<String, usize>,
805 function_name: &str,
806 function_line: usize,
807 ) {
808 match stmt {
809 Stmt::Expr(expr_stmt) => {
810 self.visit_expr_for_names(
811 &expr_stmt.value,
812 file_path,
813 content,
814 declared_params,
815 local_vars,
816 function_name,
817 function_line,
818 );
819 }
820 Stmt::Assign(assign) => {
821 self.visit_expr_for_names(
822 &assign.value,
823 file_path,
824 content,
825 declared_params,
826 local_vars,
827 function_name,
828 function_line,
829 );
830 }
831 Stmt::AugAssign(aug_assign) => {
832 self.visit_expr_for_names(
833 &aug_assign.value,
834 file_path,
835 content,
836 declared_params,
837 local_vars,
838 function_name,
839 function_line,
840 );
841 }
842 Stmt::Return(ret) => {
843 if let Some(ref value) = ret.value {
844 self.visit_expr_for_names(
845 value,
846 file_path,
847 content,
848 declared_params,
849 local_vars,
850 function_name,
851 function_line,
852 );
853 }
854 }
855 Stmt::If(if_stmt) => {
856 self.visit_expr_for_names(
857 &if_stmt.test,
858 file_path,
859 content,
860 declared_params,
861 local_vars,
862 function_name,
863 function_line,
864 );
865 for stmt in &if_stmt.body {
866 self.visit_stmt_for_names(
867 stmt,
868 file_path,
869 content,
870 declared_params,
871 local_vars,
872 function_name,
873 function_line,
874 );
875 }
876 for stmt in &if_stmt.orelse {
877 self.visit_stmt_for_names(
878 stmt,
879 file_path,
880 content,
881 declared_params,
882 local_vars,
883 function_name,
884 function_line,
885 );
886 }
887 }
888 Stmt::While(while_stmt) => {
889 self.visit_expr_for_names(
890 &while_stmt.test,
891 file_path,
892 content,
893 declared_params,
894 local_vars,
895 function_name,
896 function_line,
897 );
898 for stmt in &while_stmt.body {
899 self.visit_stmt_for_names(
900 stmt,
901 file_path,
902 content,
903 declared_params,
904 local_vars,
905 function_name,
906 function_line,
907 );
908 }
909 }
910 Stmt::For(for_stmt) => {
911 self.visit_expr_for_names(
912 &for_stmt.iter,
913 file_path,
914 content,
915 declared_params,
916 local_vars,
917 function_name,
918 function_line,
919 );
920 for stmt in &for_stmt.body {
921 self.visit_stmt_for_names(
922 stmt,
923 file_path,
924 content,
925 declared_params,
926 local_vars,
927 function_name,
928 function_line,
929 );
930 }
931 }
932 Stmt::With(with_stmt) => {
933 for item in &with_stmt.items {
934 self.visit_expr_for_names(
935 &item.context_expr,
936 file_path,
937 content,
938 declared_params,
939 local_vars,
940 function_name,
941 function_line,
942 );
943 }
944 for stmt in &with_stmt.body {
945 self.visit_stmt_for_names(
946 stmt,
947 file_path,
948 content,
949 declared_params,
950 local_vars,
951 function_name,
952 function_line,
953 );
954 }
955 }
956 Stmt::AsyncFor(for_stmt) => {
957 self.visit_expr_for_names(
958 &for_stmt.iter,
959 file_path,
960 content,
961 declared_params,
962 local_vars,
963 function_name,
964 function_line,
965 );
966 for stmt in &for_stmt.body {
967 self.visit_stmt_for_names(
968 stmt,
969 file_path,
970 content,
971 declared_params,
972 local_vars,
973 function_name,
974 function_line,
975 );
976 }
977 }
978 Stmt::AsyncWith(with_stmt) => {
979 for item in &with_stmt.items {
980 self.visit_expr_for_names(
981 &item.context_expr,
982 file_path,
983 content,
984 declared_params,
985 local_vars,
986 function_name,
987 function_line,
988 );
989 }
990 for stmt in &with_stmt.body {
991 self.visit_stmt_for_names(
992 stmt,
993 file_path,
994 content,
995 declared_params,
996 local_vars,
997 function_name,
998 function_line,
999 );
1000 }
1001 }
1002 Stmt::Assert(assert_stmt) => {
1003 self.visit_expr_for_names(
1004 &assert_stmt.test,
1005 file_path,
1006 content,
1007 declared_params,
1008 local_vars,
1009 function_name,
1010 function_line,
1011 );
1012 if let Some(ref msg) = assert_stmt.msg {
1013 self.visit_expr_for_names(
1014 msg,
1015 file_path,
1016 content,
1017 declared_params,
1018 local_vars,
1019 function_name,
1020 function_line,
1021 );
1022 }
1023 }
1024 _ => {} }
1026 }
1027
1028 #[allow(clippy::too_many_arguments)]
1029 fn visit_expr_for_names(
1030 &self,
1031 expr: &Expr,
1032 file_path: &PathBuf,
1033 content: &str,
1034 declared_params: &std::collections::HashSet<String>,
1035 local_vars: &std::collections::HashMap<String, usize>,
1036 function_name: &str,
1037 function_line: usize,
1038 ) {
1039 match expr {
1040 Expr::Name(name) => {
1041 let name_str = name.id.as_str();
1042 let line = self.get_line_from_offset(name.range.start().to_usize(), content);
1043
1044 let is_local_var_in_scope = local_vars
1048 .get(name_str)
1049 .map(|def_line| *def_line < line)
1050 .unwrap_or(false);
1051
1052 if !declared_params.contains(name_str)
1053 && !is_local_var_in_scope
1054 && self.is_available_fixture(file_path, name_str)
1055 {
1056 let start_char =
1057 self.get_char_position_from_offset(name.range.start().to_usize(), content);
1058 let end_char =
1059 self.get_char_position_from_offset(name.range.end().to_usize(), content);
1060
1061 info!(
1062 "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1063 name_str, file_path, line, start_char, function_name
1064 );
1065
1066 let undeclared = UndeclaredFixture {
1067 name: name_str.to_string(),
1068 file_path: file_path.clone(),
1069 line,
1070 start_char,
1071 end_char,
1072 function_name: function_name.to_string(),
1073 function_line,
1074 };
1075
1076 self.undeclared_fixtures
1077 .entry(file_path.clone())
1078 .or_default()
1079 .push(undeclared);
1080 }
1081 }
1082 Expr::Call(call) => {
1083 self.visit_expr_for_names(
1084 &call.func,
1085 file_path,
1086 content,
1087 declared_params,
1088 local_vars,
1089 function_name,
1090 function_line,
1091 );
1092 for arg in &call.args {
1093 self.visit_expr_for_names(
1094 arg,
1095 file_path,
1096 content,
1097 declared_params,
1098 local_vars,
1099 function_name,
1100 function_line,
1101 );
1102 }
1103 }
1104 Expr::Attribute(attr) => {
1105 self.visit_expr_for_names(
1106 &attr.value,
1107 file_path,
1108 content,
1109 declared_params,
1110 local_vars,
1111 function_name,
1112 function_line,
1113 );
1114 }
1115 Expr::BinOp(binop) => {
1116 self.visit_expr_for_names(
1117 &binop.left,
1118 file_path,
1119 content,
1120 declared_params,
1121 local_vars,
1122 function_name,
1123 function_line,
1124 );
1125 self.visit_expr_for_names(
1126 &binop.right,
1127 file_path,
1128 content,
1129 declared_params,
1130 local_vars,
1131 function_name,
1132 function_line,
1133 );
1134 }
1135 Expr::UnaryOp(unaryop) => {
1136 self.visit_expr_for_names(
1137 &unaryop.operand,
1138 file_path,
1139 content,
1140 declared_params,
1141 local_vars,
1142 function_name,
1143 function_line,
1144 );
1145 }
1146 Expr::Compare(compare) => {
1147 self.visit_expr_for_names(
1148 &compare.left,
1149 file_path,
1150 content,
1151 declared_params,
1152 local_vars,
1153 function_name,
1154 function_line,
1155 );
1156 for comparator in &compare.comparators {
1157 self.visit_expr_for_names(
1158 comparator,
1159 file_path,
1160 content,
1161 declared_params,
1162 local_vars,
1163 function_name,
1164 function_line,
1165 );
1166 }
1167 }
1168 Expr::Subscript(subscript) => {
1169 self.visit_expr_for_names(
1170 &subscript.value,
1171 file_path,
1172 content,
1173 declared_params,
1174 local_vars,
1175 function_name,
1176 function_line,
1177 );
1178 self.visit_expr_for_names(
1179 &subscript.slice,
1180 file_path,
1181 content,
1182 declared_params,
1183 local_vars,
1184 function_name,
1185 function_line,
1186 );
1187 }
1188 Expr::List(list) => {
1189 for elt in &list.elts {
1190 self.visit_expr_for_names(
1191 elt,
1192 file_path,
1193 content,
1194 declared_params,
1195 local_vars,
1196 function_name,
1197 function_line,
1198 );
1199 }
1200 }
1201 Expr::Tuple(tuple) => {
1202 for elt in &tuple.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::Dict(dict) => {
1215 for k in dict.keys.iter().flatten() {
1216 self.visit_expr_for_names(
1217 k,
1218 file_path,
1219 content,
1220 declared_params,
1221 local_vars,
1222 function_name,
1223 function_line,
1224 );
1225 }
1226 for value in &dict.values {
1227 self.visit_expr_for_names(
1228 value,
1229 file_path,
1230 content,
1231 declared_params,
1232 local_vars,
1233 function_name,
1234 function_line,
1235 );
1236 }
1237 }
1238 Expr::Await(await_expr) => {
1239 self.visit_expr_for_names(
1241 &await_expr.value,
1242 file_path,
1243 content,
1244 declared_params,
1245 local_vars,
1246 function_name,
1247 function_line,
1248 );
1249 }
1250 _ => {} }
1252 }
1253
1254 fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1255 if let Some(definitions) = self.definitions.get(fixture_name) {
1257 for def in definitions.iter() {
1259 if def.file_path == file_path {
1261 return true;
1262 }
1263
1264 if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1266 && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1267 {
1268 return true;
1269 }
1270
1271 if def.file_path.to_string_lossy().contains("site-packages") {
1273 return true;
1274 }
1275 }
1276 }
1277 false
1278 }
1279
1280 fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1281 if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1283 if let Expr::Constant(constant) = &*expr_stmt.value {
1284 if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1286 return Some(self.format_docstring(s.to_string()));
1287 }
1288 }
1289 }
1290 None
1291 }
1292
1293 fn format_docstring(&self, docstring: String) -> String {
1294 let lines: Vec<&str> = docstring.lines().collect();
1297
1298 if lines.is_empty() {
1299 return String::new();
1300 }
1301
1302 let mut start = 0;
1304 let mut end = lines.len();
1305
1306 while start < lines.len() && lines[start].trim().is_empty() {
1307 start += 1;
1308 }
1309
1310 while end > start && lines[end - 1].trim().is_empty() {
1311 end -= 1;
1312 }
1313
1314 if start >= end {
1315 return String::new();
1316 }
1317
1318 let lines = &lines[start..end];
1319
1320 let mut min_indent = usize::MAX;
1322 for (i, line) in lines.iter().enumerate() {
1323 if i == 0 && !line.trim().is_empty() {
1324 continue;
1326 }
1327
1328 if !line.trim().is_empty() {
1329 let indent = line.len() - line.trim_start().len();
1330 min_indent = min_indent.min(indent);
1331 }
1332 }
1333
1334 if min_indent == usize::MAX {
1335 min_indent = 0;
1336 }
1337
1338 let mut result = Vec::new();
1340 for (i, line) in lines.iter().enumerate() {
1341 if i == 0 {
1342 result.push(line.trim().to_string());
1344 } else if line.trim().is_empty() {
1345 result.push(String::new());
1347 } else {
1348 let dedented = if line.len() > min_indent {
1350 &line[min_indent..]
1351 } else {
1352 line.trim_start()
1353 };
1354 result.push(dedented.to_string());
1355 }
1356 }
1357
1358 result.join("\n")
1360 }
1361
1362 fn get_line_from_offset(&self, offset: usize, content: &str) -> usize {
1363 content[..offset].matches('\n').count() + 1
1365 }
1366
1367 fn get_char_position_from_offset(&self, offset: usize, content: &str) -> usize {
1368 if let Some(line_start) = content[..offset].rfind('\n') {
1370 offset - line_start - 1
1372 } else {
1373 offset
1375 }
1376 }
1377
1378 pub fn find_fixture_definition(
1380 &self,
1381 file_path: &Path,
1382 line: u32,
1383 character: u32,
1384 ) -> Option<FixtureDefinition> {
1385 debug!(
1386 "find_fixture_definition: file={:?}, line={}, char={}",
1387 file_path, line, character
1388 );
1389
1390 let target_line = (line + 1) as usize; let content = if let Some(cached) = self.file_cache.get(file_path) {
1394 cached.clone()
1395 } else {
1396 std::fs::read_to_string(file_path).ok()?
1397 };
1398 let lines: Vec<&str> = content.lines().collect();
1399
1400 if target_line == 0 || target_line > lines.len() {
1401 return None;
1402 }
1403
1404 let line_content = lines[target_line - 1];
1405 debug!("Line content: {}", line_content);
1406
1407 let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1409 debug!("Word at cursor: {:?}", word_at_cursor);
1410
1411 let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1414
1415 if let Some(usages) = self.usages.get(file_path) {
1418 for usage in usages.iter() {
1419 if usage.line == target_line && usage.name == word_at_cursor {
1420 let cursor_pos = character as usize;
1422 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1423 debug!(
1424 "Cursor at {} is within usage range {}-{}: {}",
1425 cursor_pos, usage.start_char, usage.end_char, usage.name
1426 );
1427 info!("Found fixture usage at cursor position: {}", usage.name);
1428
1429 if let Some(ref current_def) = current_fixture_def {
1431 if current_def.name == word_at_cursor {
1432 info!(
1433 "Self-referencing fixture detected, finding parent definition"
1434 );
1435 return self.find_closest_definition_excluding(
1436 file_path,
1437 &usage.name,
1438 Some(current_def),
1439 );
1440 }
1441 }
1442
1443 return self.find_closest_definition(file_path, &usage.name);
1445 }
1446 }
1447 }
1448 }
1449
1450 debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1451 None
1452 }
1453
1454 fn get_fixture_definition_at_line(
1456 &self,
1457 file_path: &Path,
1458 line: usize,
1459 ) -> Option<FixtureDefinition> {
1460 for entry in self.definitions.iter() {
1461 for def in entry.value().iter() {
1462 if def.file_path == file_path && def.line == line {
1463 return Some(def.clone());
1464 }
1465 }
1466 }
1467 None
1468 }
1469
1470 pub fn get_definition_at_line(
1473 &self,
1474 file_path: &Path,
1475 line: usize,
1476 fixture_name: &str,
1477 ) -> Option<FixtureDefinition> {
1478 if let Some(definitions) = self.definitions.get(fixture_name) {
1479 for def in definitions.iter() {
1480 if def.file_path == file_path && def.line == line {
1481 return Some(def.clone());
1482 }
1483 }
1484 }
1485 None
1486 }
1487
1488 fn find_closest_definition(
1489 &self,
1490 file_path: &Path,
1491 fixture_name: &str,
1492 ) -> Option<FixtureDefinition> {
1493 let definitions = self.definitions.get(fixture_name)?;
1494
1495 debug!(
1498 "Checking for fixture {} in same file: {:?}",
1499 fixture_name, file_path
1500 );
1501 let same_file_defs: Vec<_> = definitions
1502 .iter()
1503 .filter(|def| def.file_path == file_path)
1504 .collect();
1505
1506 if !same_file_defs.is_empty() {
1507 let last_def = same_file_defs.iter().max_by_key(|def| def.line).unwrap();
1509 info!(
1510 "Found fixture {} in same file at line {} (using last definition)",
1511 fixture_name, last_def.line
1512 );
1513 return Some((*last_def).clone());
1514 }
1515
1516 let mut current_dir = file_path.parent()?;
1519
1520 debug!(
1521 "Searching for fixture {} in conftest.py files starting from {:?}",
1522 fixture_name, current_dir
1523 );
1524 loop {
1525 let conftest_path = current_dir.join("conftest.py");
1527 debug!(" Checking conftest.py at: {:?}", conftest_path);
1528
1529 for def in definitions.iter() {
1530 if def.file_path == conftest_path {
1531 info!(
1532 "Found fixture {} in conftest.py: {:?}",
1533 fixture_name, conftest_path
1534 );
1535 return Some(def.clone());
1536 }
1537 }
1538
1539 match current_dir.parent() {
1541 Some(parent) => current_dir = parent,
1542 None => break,
1543 }
1544 }
1545
1546 warn!(
1548 "No fixture {} found following priority rules, returning first available",
1549 fixture_name
1550 );
1551 definitions.iter().next().cloned()
1552 }
1553
1554 fn find_closest_definition_excluding(
1557 &self,
1558 file_path: &Path,
1559 fixture_name: &str,
1560 exclude: Option<&FixtureDefinition>,
1561 ) -> Option<FixtureDefinition> {
1562 let definitions = self.definitions.get(fixture_name)?;
1563
1564 debug!(
1568 "Checking for fixture {} in same file: {:?} (excluding: {:?})",
1569 fixture_name, file_path, exclude
1570 );
1571 let same_file_defs: Vec<_> = definitions
1572 .iter()
1573 .filter(|def| {
1574 if def.file_path != file_path {
1575 return false;
1576 }
1577 if let Some(excluded) = exclude {
1579 if def == &excluded {
1580 debug!("Skipping excluded definition at line {}", def.line);
1581 return false;
1582 }
1583 }
1584 true
1585 })
1586 .collect();
1587
1588 if !same_file_defs.is_empty() {
1589 let last_def = same_file_defs.iter().max_by_key(|def| def.line).unwrap();
1591 info!(
1592 "Found fixture {} in same file at line {} (using last definition, excluding specified)",
1593 fixture_name, last_def.line
1594 );
1595 return Some((*last_def).clone());
1596 }
1597
1598 let mut current_dir = file_path.parent()?;
1600
1601 debug!(
1602 "Searching for fixture {} in conftest.py files starting from {:?}",
1603 fixture_name, current_dir
1604 );
1605 loop {
1606 let conftest_path = current_dir.join("conftest.py");
1607 debug!(" Checking conftest.py at: {:?}", conftest_path);
1608
1609 for def in definitions.iter() {
1610 if def.file_path == conftest_path {
1611 if let Some(excluded) = exclude {
1613 if def == excluded {
1614 debug!("Skipping excluded definition at line {}", def.line);
1615 continue;
1616 }
1617 }
1618 info!(
1619 "Found fixture {} in conftest.py: {:?}",
1620 fixture_name, conftest_path
1621 );
1622 return Some(def.clone());
1623 }
1624 }
1625
1626 match current_dir.parent() {
1628 Some(parent) => current_dir = parent,
1629 None => break,
1630 }
1631 }
1632
1633 warn!(
1635 "No fixture {} found following priority rules, returning first available (excluding specified)",
1636 fixture_name
1637 );
1638 definitions
1639 .iter()
1640 .find(|def| {
1641 if let Some(excluded) = exclude {
1642 def != &excluded
1643 } else {
1644 true
1645 }
1646 })
1647 .cloned()
1648 }
1649
1650 pub fn find_fixture_at_position(
1652 &self,
1653 file_path: &Path,
1654 line: u32,
1655 character: u32,
1656 ) -> Option<String> {
1657 let target_line = (line + 1) as usize; debug!(
1660 "find_fixture_at_position: file={:?}, line={}, char={}",
1661 file_path, target_line, character
1662 );
1663
1664 let content = if let Some(cached) = self.file_cache.get(file_path) {
1666 cached.clone()
1667 } else {
1668 std::fs::read_to_string(file_path).ok()?
1669 };
1670 let lines: Vec<&str> = content.lines().collect();
1671
1672 if target_line == 0 || target_line > lines.len() {
1673 return None;
1674 }
1675
1676 let line_content = lines[target_line - 1];
1677 debug!("Line content: {}", line_content);
1678
1679 let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
1681 debug!("Word at cursor: {:?}", word_at_cursor);
1682
1683 if let Some(usages) = self.usages.get(file_path) {
1686 for usage in usages.iter() {
1687 if usage.line == target_line {
1688 let cursor_pos = character as usize;
1690 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1691 debug!(
1692 "Cursor at {} is within usage range {}-{}: {}",
1693 cursor_pos, usage.start_char, usage.end_char, usage.name
1694 );
1695 info!("Found fixture usage at cursor position: {}", usage.name);
1696 return Some(usage.name.clone());
1697 }
1698 }
1699 }
1700 }
1701
1702 for entry in self.definitions.iter() {
1705 for def in entry.value().iter() {
1706 if def.file_path == file_path && def.line == target_line {
1707 if let Some(ref word) = word_at_cursor {
1709 if word == &def.name {
1710 info!(
1711 "Found fixture definition name at cursor position: {}",
1712 def.name
1713 );
1714 return Some(def.name.clone());
1715 }
1716 }
1717 }
1720 }
1721 }
1722
1723 debug!("No fixture found at cursor position");
1724 None
1725 }
1726
1727 fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
1728 let chars: Vec<char> = line.chars().collect();
1729
1730 if character > chars.len() {
1732 return None;
1733 }
1734
1735 if character < chars.len() {
1737 let c = chars[character];
1738 if c.is_alphanumeric() || c == '_' {
1739 let mut start = character;
1741 while start > 0 {
1742 let prev_c = chars[start - 1];
1743 if !prev_c.is_alphanumeric() && prev_c != '_' {
1744 break;
1745 }
1746 start -= 1;
1747 }
1748
1749 let mut end = character;
1750 while end < chars.len() {
1751 let curr_c = chars[end];
1752 if !curr_c.is_alphanumeric() && curr_c != '_' {
1753 break;
1754 }
1755 end += 1;
1756 }
1757
1758 if start < end {
1759 return Some(chars[start..end].iter().collect());
1760 }
1761 }
1762 }
1763
1764 None
1765 }
1766
1767 pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
1769 info!("Finding all references for fixture: {}", fixture_name);
1770
1771 let mut all_references = Vec::new();
1772
1773 for entry in self.usages.iter() {
1775 let file_path = entry.key();
1776 let usages = entry.value();
1777
1778 for usage in usages.iter() {
1780 if usage.name == fixture_name {
1781 debug!(
1782 "Found reference to {} in {:?} at line {}",
1783 fixture_name, file_path, usage.line
1784 );
1785 all_references.push(usage.clone());
1786 }
1787 }
1788 }
1789
1790 info!(
1791 "Found {} total references for fixture: {}",
1792 all_references.len(),
1793 fixture_name
1794 );
1795 all_references
1796 }
1797
1798 pub fn find_references_for_definition(
1805 &self,
1806 definition: &FixtureDefinition,
1807 ) -> Vec<FixtureUsage> {
1808 info!(
1809 "Finding references for specific definition: {} at {:?}:{}",
1810 definition.name, definition.file_path, definition.line
1811 );
1812
1813 let mut matching_references = Vec::new();
1814
1815 for entry in self.usages.iter() {
1817 let file_path = entry.key();
1818 let usages = entry.value();
1819
1820 for usage in usages.iter() {
1821 if usage.name == definition.name {
1822 let fixture_def_at_line =
1825 self.get_fixture_definition_at_line(file_path, usage.line);
1826
1827 let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
1828 if current_def.name == usage.name {
1829 debug!(
1831 "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
1832 file_path, usage.line, current_def.line
1833 );
1834 self.find_closest_definition_excluding(
1835 file_path,
1836 &usage.name,
1837 Some(current_def),
1838 )
1839 } else {
1840 self.find_closest_definition(file_path, &usage.name)
1842 }
1843 } else {
1844 self.find_closest_definition(file_path, &usage.name)
1846 };
1847
1848 if let Some(resolved_def) = resolved_def {
1849 if resolved_def == *definition {
1850 debug!(
1851 "Usage at {:?}:{} resolves to our definition",
1852 file_path, usage.line
1853 );
1854 matching_references.push(usage.clone());
1855 } else {
1856 debug!(
1857 "Usage at {:?}:{} resolves to different definition at {:?}:{}",
1858 file_path, usage.line, resolved_def.file_path, resolved_def.line
1859 );
1860 }
1861 }
1862 }
1863 }
1864 }
1865
1866 info!(
1867 "Found {} references that resolve to this specific definition",
1868 matching_references.len()
1869 );
1870 matching_references
1871 }
1872
1873 pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
1875 self.undeclared_fixtures
1876 .get(file_path)
1877 .map(|entry| entry.value().clone())
1878 .unwrap_or_default()
1879 }
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884 use super::*;
1885 use std::path::PathBuf;
1886
1887 #[test]
1888 fn test_fixture_definition_detection() {
1889 let db = FixtureDatabase::new();
1890
1891 let conftest_content = r#"
1892import pytest
1893
1894@pytest.fixture
1895def my_fixture():
1896 return 42
1897
1898@fixture
1899def another_fixture():
1900 return "hello"
1901"#;
1902
1903 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
1904 db.analyze_file(conftest_path.clone(), conftest_content);
1905
1906 assert!(db.definitions.contains_key("my_fixture"));
1908 assert!(db.definitions.contains_key("another_fixture"));
1909
1910 let my_fixture_defs = db.definitions.get("my_fixture").unwrap();
1912 assert_eq!(my_fixture_defs.len(), 1);
1913 assert_eq!(my_fixture_defs[0].name, "my_fixture");
1914 assert_eq!(my_fixture_defs[0].file_path, conftest_path);
1915 }
1916
1917 #[test]
1918 fn test_fixture_usage_detection() {
1919 let db = FixtureDatabase::new();
1920
1921 let test_content = r#"
1922def test_something(my_fixture, another_fixture):
1923 assert my_fixture == 42
1924 assert another_fixture == "hello"
1925
1926def test_other(my_fixture):
1927 assert my_fixture > 0
1928"#;
1929
1930 let test_path = PathBuf::from("/tmp/test/test_example.py");
1931 db.analyze_file(test_path.clone(), test_content);
1932
1933 assert!(db.usages.contains_key(&test_path));
1935
1936 let usages = db.usages.get(&test_path).unwrap();
1937 assert!(usages.iter().any(|u| u.name == "my_fixture"));
1939 assert!(usages.iter().any(|u| u.name == "another_fixture"));
1940 }
1941
1942 #[test]
1943 fn test_go_to_definition() {
1944 let db = FixtureDatabase::new();
1945
1946 let conftest_content = r#"
1948import pytest
1949
1950@pytest.fixture
1951def my_fixture():
1952 return 42
1953"#;
1954
1955 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
1956 db.analyze_file(conftest_path.clone(), conftest_content);
1957
1958 let test_content = r#"
1960def test_something(my_fixture):
1961 assert my_fixture == 42
1962"#;
1963
1964 let test_path = PathBuf::from("/tmp/test/test_example.py");
1965 db.analyze_file(test_path.clone(), test_content);
1966
1967 let definition = db.find_fixture_definition(&test_path, 1, 19);
1972
1973 assert!(definition.is_some(), "Definition should be found");
1974 let def = definition.unwrap();
1975 assert_eq!(def.name, "my_fixture");
1976 assert_eq!(def.file_path, conftest_path);
1977 }
1978
1979 #[test]
1980 fn test_fixture_decorator_variations() {
1981 let db = FixtureDatabase::new();
1982
1983 let conftest_content = r#"
1984import pytest
1985from pytest import fixture
1986
1987@pytest.fixture
1988def fixture1():
1989 pass
1990
1991@pytest.fixture()
1992def fixture2():
1993 pass
1994
1995@fixture
1996def fixture3():
1997 pass
1998
1999@fixture()
2000def fixture4():
2001 pass
2002"#;
2003
2004 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2005 db.analyze_file(conftest_path, conftest_content);
2006
2007 assert!(db.definitions.contains_key("fixture1"));
2009 assert!(db.definitions.contains_key("fixture2"));
2010 assert!(db.definitions.contains_key("fixture3"));
2011 assert!(db.definitions.contains_key("fixture4"));
2012 }
2013
2014 #[test]
2015 fn test_fixture_in_test_file() {
2016 let db = FixtureDatabase::new();
2017
2018 let test_content = r#"
2020import pytest
2021
2022@pytest.fixture
2023def local_fixture():
2024 return 42
2025
2026def test_something(local_fixture):
2027 assert local_fixture == 42
2028"#;
2029
2030 let test_path = PathBuf::from("/tmp/test/test_example.py");
2031 db.analyze_file(test_path.clone(), test_content);
2032
2033 assert!(db.definitions.contains_key("local_fixture"));
2035
2036 let local_fixture_defs = db.definitions.get("local_fixture").unwrap();
2037 assert_eq!(local_fixture_defs.len(), 1);
2038 assert_eq!(local_fixture_defs[0].name, "local_fixture");
2039 assert_eq!(local_fixture_defs[0].file_path, test_path);
2040
2041 assert!(db.usages.contains_key(&test_path));
2043 let usages = db.usages.get(&test_path).unwrap();
2044 assert!(usages.iter().any(|u| u.name == "local_fixture"));
2045
2046 let usage_line = usages
2048 .iter()
2049 .find(|u| u.name == "local_fixture")
2050 .map(|u| u.line)
2051 .unwrap();
2052
2053 let definition = db.find_fixture_definition(&test_path, (usage_line - 1) as u32, 19);
2055 assert!(
2056 definition.is_some(),
2057 "Should find definition for fixture in same file. Line: {}, char: 19",
2058 usage_line
2059 );
2060 let def = definition.unwrap();
2061 assert_eq!(def.name, "local_fixture");
2062 assert_eq!(def.file_path, test_path);
2063 }
2064
2065 #[test]
2066 fn test_async_test_functions() {
2067 let db = FixtureDatabase::new();
2068
2069 let test_content = r#"
2071import pytest
2072
2073@pytest.fixture
2074def my_fixture():
2075 return 42
2076
2077async def test_async_function(my_fixture):
2078 assert my_fixture == 42
2079
2080def test_sync_function(my_fixture):
2081 assert my_fixture == 42
2082"#;
2083
2084 let test_path = PathBuf::from("/tmp/test/test_async.py");
2085 db.analyze_file(test_path.clone(), test_content);
2086
2087 assert!(db.definitions.contains_key("my_fixture"));
2089
2090 assert!(db.usages.contains_key(&test_path));
2092 let usages = db.usages.get(&test_path).unwrap();
2093
2094 let fixture_usages: Vec<_> = usages.iter().filter(|u| u.name == "my_fixture").collect();
2096 assert_eq!(
2097 fixture_usages.len(),
2098 2,
2099 "Should detect fixture usage in both async and sync tests"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_extract_word_at_position() {
2105 let db = FixtureDatabase::new();
2106
2107 let line = "def test_something(my_fixture):";
2109
2110 assert_eq!(
2112 db.extract_word_at_position(line, 19),
2113 Some("my_fixture".to_string())
2114 );
2115
2116 assert_eq!(
2118 db.extract_word_at_position(line, 20),
2119 Some("my_fixture".to_string())
2120 );
2121
2122 assert_eq!(
2124 db.extract_word_at_position(line, 28),
2125 Some("my_fixture".to_string())
2126 );
2127
2128 assert_eq!(
2130 db.extract_word_at_position(line, 0),
2131 Some("def".to_string())
2132 );
2133
2134 assert_eq!(db.extract_word_at_position(line, 3), None);
2136
2137 assert_eq!(
2139 db.extract_word_at_position(line, 4),
2140 Some("test_something".to_string())
2141 );
2142
2143 assert_eq!(db.extract_word_at_position(line, 18), None);
2145
2146 assert_eq!(db.extract_word_at_position(line, 29), None);
2148
2149 assert_eq!(db.extract_word_at_position(line, 31), None);
2151 }
2152
2153 #[test]
2154 fn test_extract_word_at_position_fixture_definition() {
2155 let db = FixtureDatabase::new();
2156
2157 let line = "@pytest.fixture";
2158
2159 assert_eq!(db.extract_word_at_position(line, 0), None);
2161
2162 assert_eq!(
2164 db.extract_word_at_position(line, 1),
2165 Some("pytest".to_string())
2166 );
2167
2168 assert_eq!(db.extract_word_at_position(line, 7), None);
2170
2171 assert_eq!(
2173 db.extract_word_at_position(line, 8),
2174 Some("fixture".to_string())
2175 );
2176
2177 let line2 = "def foo(other_fixture):";
2178
2179 assert_eq!(
2181 db.extract_word_at_position(line2, 0),
2182 Some("def".to_string())
2183 );
2184
2185 assert_eq!(db.extract_word_at_position(line2, 3), None);
2187
2188 assert_eq!(
2190 db.extract_word_at_position(line2, 4),
2191 Some("foo".to_string())
2192 );
2193
2194 assert_eq!(
2196 db.extract_word_at_position(line2, 8),
2197 Some("other_fixture".to_string())
2198 );
2199
2200 assert_eq!(db.extract_word_at_position(line2, 7), None);
2202 }
2203
2204 #[test]
2205 fn test_word_detection_only_on_fixtures() {
2206 let db = FixtureDatabase::new();
2207
2208 let conftest_content = r#"
2210import pytest
2211
2212@pytest.fixture
2213def my_fixture():
2214 return 42
2215"#;
2216 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2217 db.analyze_file(conftest_path.clone(), conftest_content);
2218
2219 let test_content = r#"
2221def test_something(my_fixture, regular_param):
2222 assert my_fixture == 42
2223"#;
2224 let test_path = PathBuf::from("/tmp/test/test_example.py");
2225 db.analyze_file(test_path.clone(), test_content);
2226
2227 assert_eq!(db.find_fixture_definition(&test_path, 1, 0), None);
2236
2237 assert_eq!(db.find_fixture_definition(&test_path, 1, 4), None);
2239
2240 let result = db.find_fixture_definition(&test_path, 1, 19);
2242 assert!(result.is_some());
2243 let def = result.unwrap();
2244 assert_eq!(def.name, "my_fixture");
2245
2246 assert_eq!(db.find_fixture_definition(&test_path, 1, 31), None);
2248
2249 assert_eq!(db.find_fixture_definition(&test_path, 1, 18), None); assert_eq!(db.find_fixture_definition(&test_path, 1, 29), None); }
2253
2254 #[test]
2255 fn test_self_referencing_fixture() {
2256 let db = FixtureDatabase::new();
2257
2258 let parent_conftest_content = r#"
2260import pytest
2261
2262@pytest.fixture
2263def foo():
2264 return "parent"
2265"#;
2266 let parent_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2267 db.analyze_file(parent_conftest_path.clone(), parent_conftest_content);
2268
2269 let child_conftest_content = r#"
2271import pytest
2272
2273@pytest.fixture
2274def foo(foo):
2275 return foo + " child"
2276"#;
2277 let child_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2278 db.analyze_file(child_conftest_path.clone(), child_conftest_content);
2279
2280 let result = db.find_fixture_definition(&child_conftest_path, 4, 8);
2286
2287 assert!(
2288 result.is_some(),
2289 "Should find parent definition for self-referencing fixture"
2290 );
2291 let def = result.unwrap();
2292 assert_eq!(def.name, "foo");
2293 assert_eq!(
2294 def.file_path, parent_conftest_path,
2295 "Should resolve to parent conftest.py, not the child"
2296 );
2297 assert_eq!(def.line, 5, "Should point to line 5 of parent conftest.py");
2298 }
2299
2300 #[test]
2301 fn test_fixture_overriding_same_file() {
2302 let db = FixtureDatabase::new();
2303
2304 let test_content = r#"
2306import pytest
2307
2308@pytest.fixture
2309def my_fixture():
2310 return "first"
2311
2312@pytest.fixture
2313def my_fixture():
2314 return "second"
2315
2316def test_something(my_fixture):
2317 assert my_fixture == "second"
2318"#;
2319 let test_path = PathBuf::from("/tmp/test/test_example.py");
2320 db.analyze_file(test_path.clone(), test_content);
2321
2322 let result = db.find_fixture_definition(&test_path, 11, 19);
2331
2332 assert!(result.is_some(), "Should find fixture definition");
2333 let def = result.unwrap();
2334 assert_eq!(def.name, "my_fixture");
2335 assert_eq!(def.file_path, test_path);
2336 }
2340
2341 #[test]
2342 fn test_fixture_overriding_conftest_hierarchy() {
2343 let db = FixtureDatabase::new();
2344
2345 let root_conftest_content = r#"
2347import pytest
2348
2349@pytest.fixture
2350def shared_fixture():
2351 return "root"
2352"#;
2353 let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2354 db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2355
2356 let sub_conftest_content = r#"
2358import pytest
2359
2360@pytest.fixture
2361def shared_fixture():
2362 return "subdir"
2363"#;
2364 let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2365 db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2366
2367 let test_content = r#"
2369def test_something(shared_fixture):
2370 assert shared_fixture == "subdir"
2371"#;
2372 let test_path = PathBuf::from("/tmp/test/subdir/test_example.py");
2373 db.analyze_file(test_path.clone(), test_content);
2374
2375 let result = db.find_fixture_definition(&test_path, 1, 19);
2381
2382 assert!(result.is_some(), "Should find fixture definition");
2383 let def = result.unwrap();
2384 assert_eq!(def.name, "shared_fixture");
2385 assert_eq!(
2386 def.file_path, sub_conftest_path,
2387 "Should resolve to closest conftest.py"
2388 );
2389
2390 let parent_test_content = r#"
2392def test_parent(shared_fixture):
2393 assert shared_fixture == "root"
2394"#;
2395 let parent_test_path = PathBuf::from("/tmp/test/test_parent.py");
2396 db.analyze_file(parent_test_path.clone(), parent_test_content);
2397
2398 let result = db.find_fixture_definition(&parent_test_path, 1, 16);
2399
2400 assert!(result.is_some(), "Should find fixture definition");
2401 let def = result.unwrap();
2402 assert_eq!(def.name, "shared_fixture");
2403 assert_eq!(
2404 def.file_path, root_conftest_path,
2405 "Should resolve to root conftest.py"
2406 );
2407 }
2408
2409 #[test]
2410 fn test_scoped_references() {
2411 let db = FixtureDatabase::new();
2412
2413 let root_conftest_content = r#"
2415import pytest
2416
2417@pytest.fixture
2418def shared_fixture():
2419 return "root"
2420"#;
2421 let root_conftest_path = PathBuf::from("/tmp/test/conftest.py");
2422 db.analyze_file(root_conftest_path.clone(), root_conftest_content);
2423
2424 let sub_conftest_content = r#"
2426import pytest
2427
2428@pytest.fixture
2429def shared_fixture():
2430 return "subdir"
2431"#;
2432 let sub_conftest_path = PathBuf::from("/tmp/test/subdir/conftest.py");
2433 db.analyze_file(sub_conftest_path.clone(), sub_conftest_content);
2434
2435 let root_test_content = r#"
2437def test_root(shared_fixture):
2438 assert shared_fixture == "root"
2439"#;
2440 let root_test_path = PathBuf::from("/tmp/test/test_root.py");
2441 db.analyze_file(root_test_path.clone(), root_test_content);
2442
2443 let sub_test_content = r#"
2445def test_sub(shared_fixture):
2446 assert shared_fixture == "subdir"
2447"#;
2448 let sub_test_path = PathBuf::from("/tmp/test/subdir/test_sub.py");
2449 db.analyze_file(sub_test_path.clone(), sub_test_content);
2450
2451 let sub_test2_content = r#"
2453def test_sub2(shared_fixture):
2454 assert shared_fixture == "subdir"
2455"#;
2456 let sub_test2_path = PathBuf::from("/tmp/test/subdir/test_sub2.py");
2457 db.analyze_file(sub_test2_path.clone(), sub_test2_content);
2458
2459 let root_definitions = db.definitions.get("shared_fixture").unwrap();
2461 let root_definition = root_definitions
2462 .iter()
2463 .find(|d| d.file_path == root_conftest_path)
2464 .unwrap();
2465
2466 let sub_definition = root_definitions
2468 .iter()
2469 .find(|d| d.file_path == sub_conftest_path)
2470 .unwrap();
2471
2472 let root_refs = db.find_references_for_definition(root_definition);
2474
2475 assert_eq!(
2477 root_refs.len(),
2478 1,
2479 "Root definition should have 1 reference (from root test)"
2480 );
2481 assert_eq!(root_refs[0].file_path, root_test_path);
2482
2483 let sub_refs = db.find_references_for_definition(sub_definition);
2485
2486 assert_eq!(
2488 sub_refs.len(),
2489 2,
2490 "Subdir definition should have 2 references (from subdir tests)"
2491 );
2492
2493 let sub_ref_paths: Vec<_> = sub_refs.iter().map(|r| &r.file_path).collect();
2494 assert!(sub_ref_paths.contains(&&sub_test_path));
2495 assert!(sub_ref_paths.contains(&&sub_test2_path));
2496
2497 let all_refs = db.find_fixture_references("shared_fixture");
2499 assert_eq!(
2500 all_refs.len(),
2501 3,
2502 "Should find 3 total references across all scopes"
2503 );
2504 }
2505
2506 #[test]
2507 fn test_multiline_parameters() {
2508 let db = FixtureDatabase::new();
2509
2510 let conftest_content = r#"
2512import pytest
2513
2514@pytest.fixture
2515def foo():
2516 return 42
2517"#;
2518 let conftest_path = PathBuf::from("/tmp/test/conftest.py");
2519 db.analyze_file(conftest_path.clone(), conftest_content);
2520
2521 let test_content = r#"
2523def test_xxx(
2524 foo,
2525):
2526 assert foo == 42
2527"#;
2528 let test_path = PathBuf::from("/tmp/test/test_example.py");
2529 db.analyze_file(test_path.clone(), test_content);
2530
2531 if let Some(usages) = db.usages.get(&test_path) {
2537 println!("Usages recorded:");
2538 for usage in usages.iter() {
2539 println!(" {} at line {} (1-indexed)", usage.name, usage.line);
2540 }
2541 } else {
2542 println!("No usages recorded for test file");
2543 }
2544
2545 let result = db.find_fixture_definition(&test_path, 2, 4);
2554
2555 assert!(
2556 result.is_some(),
2557 "Should find fixture definition when cursor is on parameter line"
2558 );
2559 let def = result.unwrap();
2560 assert_eq!(def.name, "foo");
2561 }
2562
2563 #[test]
2564 fn test_find_references_from_usage() {
2565 let db = FixtureDatabase::new();
2566
2567 let test_content = r#"
2569import pytest
2570
2571@pytest.fixture
2572def foo(): ...
2573
2574
2575def test_xxx(foo):
2576 pass
2577"#;
2578 let test_path = PathBuf::from("/tmp/test/test_example.py");
2579 db.analyze_file(test_path.clone(), test_content);
2580
2581 let foo_defs = db.definitions.get("foo").unwrap();
2583 assert_eq!(foo_defs.len(), 1, "Should have exactly one foo definition");
2584 let foo_def = &foo_defs[0];
2585 assert_eq!(foo_def.line, 5, "foo definition should be on line 5");
2586
2587 let refs_from_def = db.find_references_for_definition(foo_def);
2589 println!("References from definition:");
2590 for r in &refs_from_def {
2591 println!(" {} at line {}", r.name, r.line);
2592 }
2593
2594 assert_eq!(
2595 refs_from_def.len(),
2596 1,
2597 "Should find 1 usage reference (test_xxx parameter)"
2598 );
2599 assert_eq!(refs_from_def[0].line, 8, "Usage should be on line 8");
2600
2601 let fixture_name = db.find_fixture_at_position(&test_path, 7, 13);
2604 println!(
2605 "\nfind_fixture_at_position(line 7, char 13): {:?}",
2606 fixture_name
2607 );
2608
2609 assert_eq!(
2610 fixture_name,
2611 Some("foo".to_string()),
2612 "Should find fixture name at usage position"
2613 );
2614
2615 let resolved_def = db.find_fixture_definition(&test_path, 7, 13);
2616 println!(
2617 "\nfind_fixture_definition(line 7, char 13): {:?}",
2618 resolved_def.as_ref().map(|d| (d.line, &d.file_path))
2619 );
2620
2621 assert!(resolved_def.is_some(), "Should resolve usage to definition");
2622 assert_eq!(
2623 resolved_def.unwrap(),
2624 *foo_def,
2625 "Should resolve to the correct definition"
2626 );
2627 }
2628
2629 #[test]
2630 fn test_find_references_with_ellipsis_body() {
2631 let db = FixtureDatabase::new();
2633
2634 let test_content = r#"@pytest.fixture
2635def foo(): ...
2636
2637
2638def test_xxx(foo):
2639 pass
2640"#;
2641 let test_path = PathBuf::from("/tmp/test/test_codegen.py");
2642 db.analyze_file(test_path.clone(), test_content);
2643
2644 let foo_defs = db.definitions.get("foo");
2646 println!(
2647 "foo definitions: {:?}",
2648 foo_defs
2649 .as_ref()
2650 .map(|defs| defs.iter().map(|d| d.line).collect::<Vec<_>>())
2651 );
2652
2653 if let Some(usages) = db.usages.get(&test_path) {
2655 println!("usages:");
2656 for u in usages.iter() {
2657 println!(" {} at line {}", u.name, u.line);
2658 }
2659 }
2660
2661 assert!(foo_defs.is_some(), "Should find foo definition");
2662 let foo_def = &foo_defs.unwrap()[0];
2663
2664 let usages = db.usages.get(&test_path).unwrap();
2666 let foo_usage = usages.iter().find(|u| u.name == "foo").unwrap();
2667
2668 let usage_lsp_line = (foo_usage.line - 1) as u32;
2670 println!("\nTesting from usage at LSP line {}", usage_lsp_line);
2671
2672 let fixture_name = db.find_fixture_at_position(&test_path, usage_lsp_line, 13);
2673 assert_eq!(
2674 fixture_name,
2675 Some("foo".to_string()),
2676 "Should find foo at usage"
2677 );
2678
2679 let def_from_usage = db.find_fixture_definition(&test_path, usage_lsp_line, 13);
2680 assert!(
2681 def_from_usage.is_some(),
2682 "Should resolve usage to definition"
2683 );
2684 assert_eq!(def_from_usage.unwrap(), *foo_def);
2685 }
2686
2687 #[test]
2688 fn test_fixture_hierarchy_parent_references() {
2689 let db = FixtureDatabase::new();
2692
2693 let parent_content = r#"
2695import pytest
2696
2697@pytest.fixture
2698def cli_runner():
2699 """Parent fixture"""
2700 return "parent"
2701"#;
2702 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2703 db.analyze_file(parent_conftest.clone(), parent_content);
2704
2705 let child_content = r#"
2707import pytest
2708
2709@pytest.fixture
2710def cli_runner(cli_runner):
2711 """Child override that uses parent"""
2712 return cli_runner
2713"#;
2714 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2715 db.analyze_file(child_conftest.clone(), child_content);
2716
2717 let test_content = r#"
2719def test_one(cli_runner):
2720 pass
2721
2722def test_two(cli_runner):
2723 pass
2724"#;
2725 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2726 db.analyze_file(test_path.clone(), test_content);
2727
2728 let parent_defs = db.definitions.get("cli_runner").unwrap();
2730 let parent_def = parent_defs
2731 .iter()
2732 .find(|d| d.file_path == parent_conftest)
2733 .unwrap();
2734
2735 println!(
2736 "\nParent definition: {:?}:{}",
2737 parent_def.file_path, parent_def.line
2738 );
2739
2740 let refs = db.find_references_for_definition(parent_def);
2742
2743 println!("\nReferences for parent definition:");
2744 for r in &refs {
2745 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2746 }
2747
2748 assert!(
2755 refs.len() <= 2,
2756 "Parent should have at most 2 references: child definition and its parameter, got {}",
2757 refs.len()
2758 );
2759
2760 let child_refs: Vec<_> = refs
2762 .iter()
2763 .filter(|r| r.file_path == child_conftest)
2764 .collect();
2765 assert!(
2766 !child_refs.is_empty(),
2767 "Parent references should include child fixture definition"
2768 );
2769
2770 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2772 assert!(
2773 test_refs.is_empty(),
2774 "Parent references should NOT include child's test file usages"
2775 );
2776 }
2777
2778 #[test]
2779 fn test_fixture_hierarchy_child_references() {
2780 let db = FixtureDatabase::new();
2783
2784 let parent_content = r#"
2786import pytest
2787
2788@pytest.fixture
2789def cli_runner():
2790 return "parent"
2791"#;
2792 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2793 db.analyze_file(parent_conftest.clone(), parent_content);
2794
2795 let child_content = r#"
2797import pytest
2798
2799@pytest.fixture
2800def cli_runner(cli_runner):
2801 return cli_runner
2802"#;
2803 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2804 db.analyze_file(child_conftest.clone(), child_content);
2805
2806 let test_content = r#"
2808def test_one(cli_runner):
2809 pass
2810
2811def test_two(cli_runner):
2812 pass
2813"#;
2814 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2815 db.analyze_file(test_path.clone(), test_content);
2816
2817 let child_defs = db.definitions.get("cli_runner").unwrap();
2819 let child_def = child_defs
2820 .iter()
2821 .find(|d| d.file_path == child_conftest)
2822 .unwrap();
2823
2824 println!(
2825 "\nChild definition: {:?}:{}",
2826 child_def.file_path, child_def.line
2827 );
2828
2829 let refs = db.find_references_for_definition(child_def);
2831
2832 println!("\nReferences for child definition:");
2833 for r in &refs {
2834 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2835 }
2836
2837 assert!(
2839 refs.len() >= 2,
2840 "Child should have at least 2 references from test file, got {}",
2841 refs.len()
2842 );
2843
2844 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2845 assert_eq!(
2846 test_refs.len(),
2847 2,
2848 "Should have 2 references from test file"
2849 );
2850 }
2851
2852 #[test]
2853 fn test_fixture_hierarchy_child_parameter_references() {
2854 let db = FixtureDatabase::new();
2857
2858 let parent_content = r#"
2860import pytest
2861
2862@pytest.fixture
2863def cli_runner():
2864 return "parent"
2865"#;
2866 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2867 db.analyze_file(parent_conftest.clone(), parent_content);
2868
2869 let child_content = r#"
2871import pytest
2872
2873@pytest.fixture
2874def cli_runner(cli_runner):
2875 return cli_runner
2876"#;
2877 let child_conftest = PathBuf::from("/tmp/project/subdir/conftest.py");
2878 db.analyze_file(child_conftest.clone(), child_content);
2879
2880 let resolved_def = db.find_fixture_definition(&child_conftest, 4, 15);
2884
2885 assert!(
2886 resolved_def.is_some(),
2887 "Child parameter should resolve to parent definition"
2888 );
2889
2890 let def = resolved_def.unwrap();
2891 assert_eq!(
2892 def.file_path, parent_conftest,
2893 "Should resolve to parent conftest"
2894 );
2895
2896 let refs = db.find_references_for_definition(&def);
2898
2899 println!("\nReferences for parent (from child parameter):");
2900 for r in &refs {
2901 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2902 }
2903
2904 let child_refs: Vec<_> = refs
2906 .iter()
2907 .filter(|r| r.file_path == child_conftest)
2908 .collect();
2909 assert!(
2910 !child_refs.is_empty(),
2911 "Parent references should include child fixture parameter"
2912 );
2913 }
2914
2915 #[test]
2916 fn test_fixture_hierarchy_usage_from_test() {
2917 let db = FixtureDatabase::new();
2920
2921 let parent_content = r#"
2923import pytest
2924
2925@pytest.fixture
2926def cli_runner():
2927 return "parent"
2928"#;
2929 let parent_conftest = PathBuf::from("/tmp/project/conftest.py");
2930 db.analyze_file(parent_conftest.clone(), parent_content);
2931
2932 let child_content = r#"
2934import pytest
2935
2936@pytest.fixture
2937def cli_runner(cli_runner):
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
2951def test_three(cli_runner):
2952 pass
2953"#;
2954 let test_path = PathBuf::from("/tmp/project/subdir/test_example.py");
2955 db.analyze_file(test_path.clone(), test_content);
2956
2957 let resolved_def = db.find_fixture_definition(&test_path, 1, 13);
2959
2960 assert!(
2961 resolved_def.is_some(),
2962 "Usage should resolve to child definition"
2963 );
2964
2965 let def = resolved_def.unwrap();
2966 assert_eq!(
2967 def.file_path, child_conftest,
2968 "Should resolve to child conftest (not parent)"
2969 );
2970
2971 let refs = db.find_references_for_definition(&def);
2973
2974 println!("\nReferences for child (from test usage):");
2975 for r in &refs {
2976 println!(" {} at {:?}:{}", r.name, r.file_path, r.line);
2977 }
2978
2979 let test_refs: Vec<_> = refs.iter().filter(|r| r.file_path == test_path).collect();
2981 assert_eq!(test_refs.len(), 3, "Should find all 3 usages in test file");
2982 }
2983
2984 #[test]
2985 fn test_fixture_hierarchy_multiple_levels() {
2986 let db = FixtureDatabase::new();
2988
2989 let grandparent_content = r#"
2991import pytest
2992
2993@pytest.fixture
2994def db():
2995 return "grandparent_db"
2996"#;
2997 let grandparent_conftest = PathBuf::from("/tmp/project/conftest.py");
2998 db.analyze_file(grandparent_conftest.clone(), grandparent_content);
2999
3000 let parent_content = r#"
3002import pytest
3003
3004@pytest.fixture
3005def db(db):
3006 return f"parent_{db}"
3007"#;
3008 let parent_conftest = PathBuf::from("/tmp/project/api/conftest.py");
3009 db.analyze_file(parent_conftest.clone(), parent_content);
3010
3011 let child_content = r#"
3013import pytest
3014
3015@pytest.fixture
3016def db(db):
3017 return f"child_{db}"
3018"#;
3019 let child_conftest = PathBuf::from("/tmp/project/api/tests/conftest.py");
3020 db.analyze_file(child_conftest.clone(), child_content);
3021
3022 let test_content = r#"
3024def test_db(db):
3025 pass
3026"#;
3027 let test_path = PathBuf::from("/tmp/project/api/tests/test_example.py");
3028 db.analyze_file(test_path.clone(), test_content);
3029
3030 let all_defs = db.definitions.get("db").unwrap();
3032 assert_eq!(all_defs.len(), 3, "Should have 3 definitions");
3033
3034 let grandparent_def = all_defs
3035 .iter()
3036 .find(|d| d.file_path == grandparent_conftest)
3037 .unwrap();
3038 let parent_def = all_defs
3039 .iter()
3040 .find(|d| d.file_path == parent_conftest)
3041 .unwrap();
3042 let child_def = all_defs
3043 .iter()
3044 .find(|d| d.file_path == child_conftest)
3045 .unwrap();
3046
3047 let resolved = db.find_fixture_definition(&test_path, 1, 12);
3049 assert_eq!(
3050 resolved.as_ref(),
3051 Some(child_def),
3052 "Test should use child definition"
3053 );
3054
3055 let child_refs = db.find_references_for_definition(child_def);
3057 let test_refs: Vec<_> = child_refs
3058 .iter()
3059 .filter(|r| r.file_path == test_path)
3060 .collect();
3061 assert!(
3062 !test_refs.is_empty(),
3063 "Child should have test file references"
3064 );
3065
3066 let parent_refs = db.find_references_for_definition(parent_def);
3068 let child_param_refs: Vec<_> = parent_refs
3069 .iter()
3070 .filter(|r| r.file_path == child_conftest)
3071 .collect();
3072 let test_refs_in_parent: Vec<_> = parent_refs
3073 .iter()
3074 .filter(|r| r.file_path == test_path)
3075 .collect();
3076
3077 assert!(
3078 !child_param_refs.is_empty(),
3079 "Parent should have child parameter reference"
3080 );
3081 assert!(
3082 test_refs_in_parent.is_empty(),
3083 "Parent should NOT have test file references"
3084 );
3085
3086 let grandparent_refs = db.find_references_for_definition(grandparent_def);
3088 let parent_param_refs: Vec<_> = grandparent_refs
3089 .iter()
3090 .filter(|r| r.file_path == parent_conftest)
3091 .collect();
3092 let child_refs_in_gp: Vec<_> = grandparent_refs
3093 .iter()
3094 .filter(|r| r.file_path == child_conftest)
3095 .collect();
3096
3097 assert!(
3098 !parent_param_refs.is_empty(),
3099 "Grandparent should have parent parameter reference"
3100 );
3101 assert!(
3102 child_refs_in_gp.is_empty(),
3103 "Grandparent should NOT have child references"
3104 );
3105 }
3106
3107 #[test]
3108 fn test_fixture_hierarchy_same_file_override() {
3109 let db = FixtureDatabase::new();
3112
3113 let content = r#"
3114import pytest
3115
3116@pytest.fixture
3117def base():
3118 return "base"
3119
3120@pytest.fixture
3121def base(base):
3122 return f"override_{base}"
3123
3124def test_uses_override(base):
3125 pass
3126"#;
3127 let test_path = PathBuf::from("/tmp/test/test_example.py");
3128 db.analyze_file(test_path.clone(), content);
3129
3130 let defs = db.definitions.get("base").unwrap();
3131 assert_eq!(defs.len(), 2, "Should have 2 definitions in same file");
3132
3133 println!("\nDefinitions found:");
3134 for d in defs.iter() {
3135 println!(" base at line {}", d.line);
3136 }
3137
3138 if let Some(usages) = db.usages.get(&test_path) {
3140 println!("\nUsages found:");
3141 for u in usages.iter() {
3142 println!(" {} at line {}", u.name, u.line);
3143 }
3144 } else {
3145 println!("\nNo usages found!");
3146 }
3147
3148 let resolved = db.find_fixture_definition(&test_path, 11, 23);
3152
3153 println!("\nResolved: {:?}", resolved.as_ref().map(|d| d.line));
3154
3155 assert!(resolved.is_some(), "Should resolve to override definition");
3156
3157 let override_def = defs.iter().find(|d| d.line == 9).unwrap();
3159 println!("Override def at line: {}", override_def.line);
3160 assert_eq!(resolved.as_ref(), Some(override_def));
3161 }
3162
3163 #[test]
3164 fn test_cursor_position_on_definition_line() {
3165 let db = FixtureDatabase::new();
3168
3169 let parent_content = r#"
3171import pytest
3172
3173@pytest.fixture
3174def cli_runner():
3175 return "parent"
3176"#;
3177 let parent_conftest = PathBuf::from("/tmp/conftest.py");
3178 db.analyze_file(parent_conftest.clone(), parent_content);
3179
3180 let content = r#"
3181import pytest
3182
3183@pytest.fixture
3184def cli_runner(cli_runner):
3185 return cli_runner
3186"#;
3187 let test_path = PathBuf::from("/tmp/test/test_example.py");
3188 db.analyze_file(test_path.clone(), content);
3189
3190 println!("\n=== Testing character positions on line 5 ===");
3197
3198 if let Some(usages) = db.usages.get(&test_path) {
3200 println!("\nUsages found:");
3201 for u in usages.iter() {
3202 println!(
3203 " {} at line {}, chars {}-{}",
3204 u.name, u.line, u.start_char, u.end_char
3205 );
3206 }
3207 } else {
3208 println!("\nNo usages found!");
3209 }
3210
3211 let line_content = "def cli_runner(cli_runner):";
3213 println!("\nLine content: '{}'", line_content);
3214
3215 println!("\nPosition 4 (function name):");
3217 let word_at_4 = db.extract_word_at_position(line_content, 4);
3218 println!(" Word at cursor: {:?}", word_at_4);
3219 let fixture_name_at_4 = db.find_fixture_at_position(&test_path, 4, 4);
3220 println!(" find_fixture_at_position: {:?}", fixture_name_at_4);
3221 let resolved_4 = db.find_fixture_definition(&test_path, 4, 4); println!(
3223 " Resolved: {:?}",
3224 resolved_4.as_ref().map(|d| (d.name.as_str(), d.line))
3225 );
3226
3227 println!("\nPosition 16 (parameter name):");
3229 let word_at_16 = db.extract_word_at_position(line_content, 16);
3230 println!(" Word at cursor: {:?}", word_at_16);
3231
3232 if let Some(usages) = db.usages.get(&test_path) {
3234 for usage in usages.iter() {
3235 println!(" Checking usage: {} at line {}", usage.name, usage.line);
3236 if usage.line == 5 && usage.name == "cli_runner" {
3237 println!(" MATCH! Usage matches our position");
3238 }
3239 }
3240 }
3241
3242 let fixture_name_at_16 = db.find_fixture_at_position(&test_path, 4, 16);
3243 println!(" find_fixture_at_position: {:?}", fixture_name_at_16);
3244 let resolved_16 = db.find_fixture_definition(&test_path, 4, 16); println!(
3246 " Resolved: {:?}",
3247 resolved_16.as_ref().map(|d| (d.name.as_str(), d.line))
3248 );
3249
3250 assert_eq!(word_at_4, Some("cli_runner".to_string()));
3255 assert_eq!(word_at_16, Some("cli_runner".to_string()));
3256
3257 println!("\n=== ACTUAL vs EXPECTED ===");
3259 println!("Position 4 (function name):");
3260 println!(
3261 " Actual: {:?}",
3262 resolved_4.as_ref().map(|d| (&d.file_path, d.line))
3263 );
3264 println!(" Expected: test file, line 5 (the child definition itself)");
3265
3266 println!("\nPosition 16 (parameter):");
3267 println!(
3268 " Actual: {:?}",
3269 resolved_16.as_ref().map(|d| (&d.file_path, d.line))
3270 );
3271 println!(" Expected: conftest, line 5 (the parent definition)");
3272
3273 if let Some(ref def) = resolved_16 {
3279 assert_eq!(
3280 def.file_path, parent_conftest,
3281 "Parameter should resolve to parent definition"
3282 );
3283 } else {
3284 panic!("Position 16 (parameter) should resolve to parent definition");
3285 }
3286 }
3287
3288 #[test]
3289 fn test_undeclared_fixture_detection_in_test() {
3290 let db = FixtureDatabase::new();
3291
3292 let conftest_content = r#"
3294import pytest
3295
3296@pytest.fixture
3297def my_fixture():
3298 return 42
3299"#;
3300 let conftest_path = PathBuf::from("/tmp/conftest.py");
3301 db.analyze_file(conftest_path.clone(), conftest_content);
3302
3303 let test_content = r#"
3305def test_example():
3306 result = my_fixture.get()
3307 assert result == 42
3308"#;
3309 let test_path = PathBuf::from("/tmp/test_example.py");
3310 db.analyze_file(test_path.clone(), test_content);
3311
3312 let undeclared = db.get_undeclared_fixtures(&test_path);
3314 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3315
3316 let fixture = &undeclared[0];
3317 assert_eq!(fixture.name, "my_fixture");
3318 assert_eq!(fixture.function_name, "test_example");
3319 assert_eq!(fixture.line, 3); }
3321
3322 #[test]
3323 fn test_undeclared_fixture_detection_in_fixture() {
3324 let db = FixtureDatabase::new();
3325
3326 let conftest_content = r#"
3328import pytest
3329
3330@pytest.fixture
3331def base_fixture():
3332 return "base"
3333
3334@pytest.fixture
3335def helper_fixture():
3336 return "helper"
3337"#;
3338 let conftest_path = PathBuf::from("/tmp/conftest.py");
3339 db.analyze_file(conftest_path.clone(), conftest_content);
3340
3341 let test_content = r#"
3343import pytest
3344
3345@pytest.fixture
3346def my_fixture(base_fixture):
3347 data = helper_fixture.value
3348 return f"{base_fixture}-{data}"
3349"#;
3350 let test_path = PathBuf::from("/tmp/test_example.py");
3351 db.analyze_file(test_path.clone(), test_content);
3352
3353 let undeclared = db.get_undeclared_fixtures(&test_path);
3355 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3356
3357 let fixture = &undeclared[0];
3358 assert_eq!(fixture.name, "helper_fixture");
3359 assert_eq!(fixture.function_name, "my_fixture");
3360 assert_eq!(fixture.line, 6); }
3362
3363 #[test]
3364 fn test_no_false_positive_for_declared_fixtures() {
3365 let db = FixtureDatabase::new();
3366
3367 let conftest_content = r#"
3369import pytest
3370
3371@pytest.fixture
3372def my_fixture():
3373 return 42
3374"#;
3375 let conftest_path = PathBuf::from("/tmp/conftest.py");
3376 db.analyze_file(conftest_path.clone(), conftest_content);
3377
3378 let test_content = r#"
3380def test_example(my_fixture):
3381 result = my_fixture
3382 assert result == 42
3383"#;
3384 let test_path = PathBuf::from("/tmp/test_example.py");
3385 db.analyze_file(test_path.clone(), test_content);
3386
3387 let undeclared = db.get_undeclared_fixtures(&test_path);
3389 assert_eq!(
3390 undeclared.len(),
3391 0,
3392 "Should not detect any undeclared fixtures"
3393 );
3394 }
3395
3396 #[test]
3397 fn test_no_false_positive_for_non_fixtures() {
3398 let db = FixtureDatabase::new();
3399
3400 let test_content = r#"
3402def test_example():
3403 my_variable = 42
3404 result = my_variable + 10
3405 assert result == 52
3406"#;
3407 let test_path = PathBuf::from("/tmp/test_example.py");
3408 db.analyze_file(test_path.clone(), test_content);
3409
3410 let undeclared = db.get_undeclared_fixtures(&test_path);
3412 assert_eq!(
3413 undeclared.len(),
3414 0,
3415 "Should not detect any undeclared fixtures"
3416 );
3417 }
3418
3419 #[test]
3420 fn test_undeclared_fixture_not_available_in_hierarchy() {
3421 let db = FixtureDatabase::new();
3422
3423 let other_conftest = r#"
3425import pytest
3426
3427@pytest.fixture
3428def other_fixture():
3429 return "other"
3430"#;
3431 let other_path = PathBuf::from("/other/conftest.py");
3432 db.analyze_file(other_path.clone(), other_conftest);
3433
3434 let test_content = r#"
3436def test_example():
3437 result = other_fixture.value
3438 assert result == "other"
3439"#;
3440 let test_path = PathBuf::from("/tmp/test_example.py");
3441 db.analyze_file(test_path.clone(), test_content);
3442
3443 let undeclared = db.get_undeclared_fixtures(&test_path);
3445 assert_eq!(
3446 undeclared.len(),
3447 0,
3448 "Should not detect fixtures not in hierarchy"
3449 );
3450 }
3451}
3452
3453#[test]
3454fn test_undeclared_fixture_in_async_test() {
3455 let db = FixtureDatabase::new();
3456
3457 let content = r#"
3459import pytest
3460
3461@pytest.fixture
3462def http_client():
3463 return "MockClient"
3464
3465async def test_with_undeclared():
3466 response = await http_client.query("test")
3467 assert response == "test"
3468"#;
3469 let test_path = PathBuf::from("/tmp/test_example.py");
3470 db.analyze_file(test_path.clone(), content);
3471
3472 let undeclared = db.get_undeclared_fixtures(&test_path);
3474
3475 println!("Found {} undeclared fixtures", undeclared.len());
3476 for u in &undeclared {
3477 println!(" - {} at line {} in {}", u.name, u.line, u.function_name);
3478 }
3479
3480 assert_eq!(undeclared.len(), 1, "Should detect one undeclared fixture");
3481 assert_eq!(undeclared[0].name, "http_client");
3482 assert_eq!(undeclared[0].function_name, "test_with_undeclared");
3483 assert_eq!(undeclared[0].line, 9);
3484}
3485
3486#[test]
3487fn test_undeclared_fixture_in_assert_statement() {
3488 let db = FixtureDatabase::new();
3489
3490 let conftest_content = r#"
3492import pytest
3493
3494@pytest.fixture
3495def expected_value():
3496 return 42
3497"#;
3498 let conftest_path = PathBuf::from("/tmp/conftest.py");
3499 db.analyze_file(conftest_path.clone(), conftest_content);
3500
3501 let test_content = r#"
3503def test_assertion():
3504 result = calculate_value()
3505 assert result == expected_value
3506"#;
3507 let test_path = PathBuf::from("/tmp/test_example.py");
3508 db.analyze_file(test_path.clone(), test_content);
3509
3510 let undeclared = db.get_undeclared_fixtures(&test_path);
3512
3513 assert_eq!(
3514 undeclared.len(),
3515 1,
3516 "Should detect one undeclared fixture in assert"
3517 );
3518 assert_eq!(undeclared[0].name, "expected_value");
3519 assert_eq!(undeclared[0].function_name, "test_assertion");
3520}
3521
3522#[test]
3523fn test_no_false_positive_for_local_variable() {
3524 let db = FixtureDatabase::new();
3526
3527 let conftest_content = r#"
3529import pytest
3530
3531@pytest.fixture
3532def foo():
3533 return "fixture"
3534"#;
3535 let conftest_path = PathBuf::from("/tmp/conftest.py");
3536 db.analyze_file(conftest_path.clone(), conftest_content);
3537
3538 let test_content = r#"
3540def test_with_local_variable():
3541 foo = "local variable"
3542 result = foo.upper()
3543 assert result == "LOCAL VARIABLE"
3544"#;
3545 let test_path = PathBuf::from("/tmp/test_example.py");
3546 db.analyze_file(test_path.clone(), test_content);
3547
3548 let undeclared = db.get_undeclared_fixtures(&test_path);
3550
3551 assert_eq!(
3552 undeclared.len(),
3553 0,
3554 "Should not detect undeclared fixture when name is a local variable"
3555 );
3556}
3557
3558#[test]
3559fn test_no_false_positive_for_imported_name() {
3560 let db = FixtureDatabase::new();
3562
3563 let conftest_content = r#"
3565import pytest
3566
3567@pytest.fixture
3568def foo():
3569 return "fixture"
3570"#;
3571 let conftest_path = PathBuf::from("/tmp/conftest.py");
3572 db.analyze_file(conftest_path.clone(), conftest_content);
3573
3574 let test_content = r#"
3576from mymodule import foo
3577
3578def test_with_import():
3579 result = foo.something()
3580 assert result == "value"
3581"#;
3582 let test_path = PathBuf::from("/tmp/test_example.py");
3583 db.analyze_file(test_path.clone(), test_content);
3584
3585 let undeclared = db.get_undeclared_fixtures(&test_path);
3587
3588 assert_eq!(
3589 undeclared.len(),
3590 0,
3591 "Should not detect undeclared fixture when name is imported"
3592 );
3593}
3594
3595#[test]
3596fn test_warn_for_fixture_used_directly() {
3597 let db = FixtureDatabase::new();
3600
3601 let test_content = r#"
3602import pytest
3603
3604@pytest.fixture
3605def foo():
3606 return "fixture"
3607
3608def test_using_fixture_directly():
3609 # This is an error - fixtures must be declared as parameters
3610 result = foo.something()
3611 assert result == "value"
3612"#;
3613 let test_path = PathBuf::from("/tmp/test_example.py");
3614 db.analyze_file(test_path.clone(), test_content);
3615
3616 let undeclared = db.get_undeclared_fixtures(&test_path);
3618
3619 assert_eq!(
3620 undeclared.len(),
3621 1,
3622 "Should detect fixture used directly without parameter declaration"
3623 );
3624 assert_eq!(undeclared[0].name, "foo");
3625 assert_eq!(undeclared[0].function_name, "test_using_fixture_directly");
3626}
3627
3628#[test]
3629fn test_no_false_positive_for_module_level_assignment() {
3630 let db = FixtureDatabase::new();
3632
3633 let conftest_content = r#"
3635import pytest
3636
3637@pytest.fixture
3638def foo():
3639 return "fixture"
3640"#;
3641 let conftest_path = PathBuf::from("/tmp/conftest.py");
3642 db.analyze_file(conftest_path.clone(), conftest_content);
3643
3644 let test_content = r#"
3646# Module-level assignment
3647foo = SomeClass()
3648
3649def test_with_module_var():
3650 result = foo.method()
3651 assert result == "value"
3652"#;
3653 let test_path = PathBuf::from("/tmp/test_example.py");
3654 db.analyze_file(test_path.clone(), test_content);
3655
3656 let undeclared = db.get_undeclared_fixtures(&test_path);
3658
3659 assert_eq!(
3660 undeclared.len(),
3661 0,
3662 "Should not detect undeclared fixture when name is assigned at module level"
3663 );
3664}
3665
3666#[test]
3667fn test_no_false_positive_for_function_definition() {
3668 let db = FixtureDatabase::new();
3670
3671 let conftest_content = r#"
3673import pytest
3674
3675@pytest.fixture
3676def foo():
3677 return "fixture"
3678"#;
3679 let conftest_path = PathBuf::from("/tmp/conftest.py");
3680 db.analyze_file(conftest_path.clone(), conftest_content);
3681
3682 let test_content = r#"
3684def foo():
3685 return "not a fixture"
3686
3687def test_with_function():
3688 result = foo()
3689 assert result == "not a fixture"
3690"#;
3691 let test_path = PathBuf::from("/tmp/test_example.py");
3692 db.analyze_file(test_path.clone(), test_content);
3693
3694 let undeclared = db.get_undeclared_fixtures(&test_path);
3696
3697 assert_eq!(
3698 undeclared.len(),
3699 0,
3700 "Should not detect undeclared fixture when name is a regular function"
3701 );
3702}
3703
3704#[test]
3705fn test_no_false_positive_for_class_definition() {
3706 let db = FixtureDatabase::new();
3708
3709 let conftest_content = r#"
3711import pytest
3712
3713@pytest.fixture
3714def MyClass():
3715 return "fixture"
3716"#;
3717 let conftest_path = PathBuf::from("/tmp/conftest.py");
3718 db.analyze_file(conftest_path.clone(), conftest_content);
3719
3720 let test_content = r#"
3722class MyClass:
3723 pass
3724
3725def test_with_class():
3726 obj = MyClass()
3727 assert obj is not None
3728"#;
3729 let test_path = PathBuf::from("/tmp/test_example.py");
3730 db.analyze_file(test_path.clone(), test_content);
3731
3732 let undeclared = db.get_undeclared_fixtures(&test_path);
3734
3735 assert_eq!(
3736 undeclared.len(),
3737 0,
3738 "Should not detect undeclared fixture when name is a class"
3739 );
3740}
3741
3742#[test]
3743fn test_line_aware_local_variable_scope() {
3744 let db = FixtureDatabase::new();
3746
3747 let conftest_content = r#"
3749import pytest
3750
3751@pytest.fixture
3752def http_client():
3753 return "MockClient"
3754"#;
3755 let conftest_path = PathBuf::from("/tmp/conftest.py");
3756 db.analyze_file(conftest_path.clone(), conftest_content);
3757
3758 let test_content = r#"async def test_example():
3760 # Line 1: http_client should be flagged (not yet assigned)
3761 result = await http_client.get("/api")
3762 # Line 3: Now we assign http_client locally
3763 http_client = "local"
3764 # Line 5: http_client should NOT be flagged (local var now)
3765 result2 = await http_client.get("/api2")
3766"#;
3767 let test_path = PathBuf::from("/tmp/test_example.py");
3768 db.analyze_file(test_path.clone(), test_content);
3769
3770 let undeclared = db.get_undeclared_fixtures(&test_path);
3772
3773 assert_eq!(
3776 undeclared.len(),
3777 1,
3778 "Should detect http_client only before local assignment"
3779 );
3780 assert_eq!(undeclared[0].name, "http_client");
3781 assert_eq!(
3783 undeclared[0].line, 3,
3784 "Should flag usage on line 3 (before assignment on line 5)"
3785 );
3786}
3787
3788#[test]
3789fn test_same_line_assignment_and_usage() {
3790 let db = FixtureDatabase::new();
3792
3793 let conftest_content = r#"import pytest
3794
3795@pytest.fixture
3796def http_client():
3797 return "parent"
3798"#;
3799 let conftest_path = PathBuf::from("/tmp/conftest.py");
3800 db.analyze_file(conftest_path.clone(), conftest_content);
3801
3802 let test_content = r#"async def test_example():
3803 # This references the fixture on the RHS, then assigns to local var
3804 http_client = await http_client.get("/api")
3805"#;
3806 let test_path = PathBuf::from("/tmp/test_example.py");
3807 db.analyze_file(test_path.clone(), test_content);
3808
3809 let undeclared = db.get_undeclared_fixtures(&test_path);
3810
3811 assert_eq!(undeclared.len(), 1);
3813 assert_eq!(undeclared[0].name, "http_client");
3814 assert_eq!(undeclared[0].line, 3);
3815}
3816
3817#[test]
3818fn test_no_false_positive_for_later_assignment() {
3819 let db = FixtureDatabase::new();
3822
3823 let conftest_content = r#"import pytest
3824
3825@pytest.fixture
3826def http_client():
3827 return "fixture"
3828"#;
3829 let conftest_path = PathBuf::from("/tmp/conftest.py");
3830 db.analyze_file(conftest_path.clone(), conftest_content);
3831
3832 let test_content = r#"async def test_example():
3835 result = await http_client.get("/api") # Should be flagged
3836 # Now assign locally
3837 http_client = "local"
3838 # This should NOT be flagged because variable is now assigned
3839 result2 = http_client
3840"#;
3841 let test_path = PathBuf::from("/tmp/test_example.py");
3842 db.analyze_file(test_path.clone(), test_content);
3843
3844 let undeclared = db.get_undeclared_fixtures(&test_path);
3845
3846 assert_eq!(
3848 undeclared.len(),
3849 1,
3850 "Should detect exactly one undeclared fixture"
3851 );
3852 assert_eq!(undeclared[0].name, "http_client");
3853 assert_eq!(
3854 undeclared[0].line, 2,
3855 "Should flag usage on line 2 before assignment on line 4"
3856 );
3857}