1use dashmap::DashMap;
2use rustpython_parser::ast::{Expr, Stmt};
3use rustpython_parser::{parse, Mode};
4use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tracing::{debug, info, warn};
8use walkdir::WalkDir;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct FixtureDefinition {
12 pub name: String,
13 pub file_path: PathBuf,
14 pub line: usize,
15 pub docstring: Option<String>,
16 pub return_type: Option<String>, }
18
19#[derive(Debug, Clone)]
20pub struct FixtureUsage {
21 pub name: String,
22 pub file_path: PathBuf,
23 pub line: usize,
24 pub start_char: usize, pub end_char: usize, }
27
28#[derive(Debug, Clone)]
29pub struct UndeclaredFixture {
30 pub name: String,
31 pub file_path: PathBuf,
32 pub line: usize,
33 pub start_char: usize,
34 pub end_char: usize,
35 pub function_name: String, pub function_line: usize, }
38
39#[derive(Debug)]
40pub struct FixtureDatabase {
41 pub definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
43 pub usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
45 pub file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
47 pub undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
49 pub imports: Arc<DashMap<PathBuf, std::collections::HashSet<String>>>,
51}
52
53impl Default for FixtureDatabase {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl FixtureDatabase {
60 pub fn new() -> Self {
61 Self {
62 definitions: Arc::new(DashMap::new()),
63 usages: Arc::new(DashMap::new()),
64 file_cache: Arc::new(DashMap::new()),
65 undeclared_fixtures: Arc::new(DashMap::new()),
66 imports: Arc::new(DashMap::new()),
67 }
68 }
69
70 fn get_file_content(&self, file_path: &Path) -> Option<Arc<String>> {
73 if let Some(cached) = self.file_cache.get(file_path) {
74 Some(Arc::clone(cached.value()))
75 } else {
76 std::fs::read_to_string(file_path).ok().map(Arc::new)
77 }
78 }
79
80 pub fn scan_workspace(&self, root_path: &Path) {
82 info!("Scanning workspace: {:?}", root_path);
83 let mut file_count = 0;
84
85 for entry in WalkDir::new(root_path).into_iter().filter_map(|e| e.ok()) {
86 let path = entry.path();
87
88 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
90 if filename == "conftest.py"
91 || filename.starts_with("test_") && filename.ends_with(".py")
92 || filename.ends_with("_test.py")
93 {
94 debug!("Found test/conftest file: {:?}", path);
95 if let Ok(content) = std::fs::read_to_string(path) {
96 self.analyze_file(path.to_path_buf(), &content);
97 file_count += 1;
98 }
99 }
100 }
101 }
102
103 info!("Workspace scan complete. Processed {} files", file_count);
104
105 self.scan_venv_fixtures(root_path);
107
108 info!("Total fixtures defined: {}", self.definitions.len());
109 info!("Total files with fixture usages: {}", self.usages.len());
110 }
111
112 fn scan_venv_fixtures(&self, root_path: &Path) {
114 info!("Scanning for pytest plugins in virtual environment");
115
116 let venv_paths = vec![
118 root_path.join(".venv"),
119 root_path.join("venv"),
120 root_path.join("env"),
121 ];
122
123 info!("Checking for venv in: {:?}", root_path);
124 for venv_path in &venv_paths {
125 debug!("Checking venv path: {:?}", venv_path);
126 if venv_path.exists() {
127 info!("Found virtual environment at: {:?}", venv_path);
128 self.scan_venv_site_packages(venv_path);
129 return;
130 } else {
131 debug!(" Does not exist: {:?}", venv_path);
132 }
133 }
134
135 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
137 info!("Found VIRTUAL_ENV environment variable: {}", venv);
138 let venv_path = PathBuf::from(venv);
139 if venv_path.exists() {
140 info!("Using VIRTUAL_ENV: {:?}", venv_path);
141 self.scan_venv_site_packages(&venv_path);
142 return;
143 } else {
144 warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
145 }
146 } else {
147 debug!("No VIRTUAL_ENV environment variable set");
148 }
149
150 warn!("No virtual environment found - third-party fixtures will not be available");
151 }
152
153 fn scan_venv_site_packages(&self, venv_path: &Path) {
154 info!("Scanning venv site-packages in: {:?}", venv_path);
155
156 let lib_path = venv_path.join("lib");
158 debug!("Checking lib path: {:?}", lib_path);
159
160 if lib_path.exists() {
161 if let Ok(entries) = std::fs::read_dir(&lib_path) {
163 for entry in entries.flatten() {
164 let path = entry.path();
165 let dirname = path.file_name().unwrap_or_default().to_string_lossy();
166 debug!("Found in lib: {:?}", dirname);
167
168 if path.is_dir() && dirname.starts_with("python") {
169 let site_packages = path.join("site-packages");
170 debug!("Checking site-packages: {:?}", site_packages);
171
172 if site_packages.exists() {
173 info!("Found site-packages: {:?}", site_packages);
174 self.scan_pytest_plugins(&site_packages);
175 return;
176 }
177 }
178 }
179 }
180 }
181
182 let windows_site_packages = venv_path.join("Lib/site-packages");
184 debug!("Checking Windows path: {:?}", windows_site_packages);
185 if windows_site_packages.exists() {
186 info!("Found site-packages (Windows): {:?}", windows_site_packages);
187 self.scan_pytest_plugins(&windows_site_packages);
188 return;
189 }
190
191 warn!("Could not find site-packages in venv: {:?}", venv_path);
192 }
193
194 fn scan_pytest_plugins(&self, site_packages: &Path) {
195 info!("Scanning pytest plugins in: {:?}", site_packages);
196
197 let pytest_packages = vec![
199 "pytest_mock",
200 "pytest-mock",
201 "pytest_asyncio",
202 "pytest-asyncio",
203 "pytest_django",
204 "pytest-django",
205 "pytest_cov",
206 "pytest-cov",
207 "pytest_xdist",
208 "pytest-xdist",
209 "pytest_fixtures",
210 ];
211
212 let mut plugin_count = 0;
213
214 for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
215 let entry = match entry {
216 Ok(e) => e,
217 Err(_) => continue,
218 };
219
220 let path = entry.path();
221 let filename = path.file_name().unwrap_or_default().to_string_lossy();
222
223 let is_pytest_package = pytest_packages.iter().any(|pkg| filename.contains(pkg))
225 || filename.starts_with("pytest")
226 || filename.contains("_pytest");
227
228 if is_pytest_package && path.is_dir() {
229 if filename.ends_with(".dist-info") || filename.ends_with(".egg-info") {
231 debug!("Skipping dist-info directory: {:?}", filename);
232 continue;
233 }
234
235 info!("Scanning pytest plugin: {:?}", path);
236 plugin_count += 1;
237 self.scan_plugin_directory(&path);
238 } else {
239 if filename.contains("mock") {
241 debug!("Found mock-related package (not scanning): {:?}", filename);
242 }
243 }
244 }
245
246 info!("Scanned {} pytest plugin packages", plugin_count);
247 }
248
249 fn scan_plugin_directory(&self, plugin_dir: &Path) {
250 for entry in WalkDir::new(plugin_dir)
252 .max_depth(3) .into_iter()
254 .filter_map(|e| e.ok())
255 {
256 let path = entry.path();
257
258 if path.extension().and_then(|s| s.to_str()) == Some("py") {
259 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
261 if filename.starts_with("test_") || filename.contains("__pycache__") {
263 continue;
264 }
265
266 debug!("Scanning plugin file: {:?}", path);
267 if let Ok(content) = std::fs::read_to_string(path) {
268 self.analyze_file(path.to_path_buf(), &content);
269 }
270 }
271 }
272 }
273 }
274
275 pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
277 let file_path = file_path.canonicalize().unwrap_or_else(|_| {
280 debug!(
283 "Warning: Could not canonicalize path {:?}, using as-is",
284 file_path
285 );
286 file_path
287 });
288
289 debug!("Analyzing file: {:?}", file_path);
290
291 self.file_cache
294 .insert(file_path.clone(), Arc::new(content.to_string()));
295
296 let parsed = match parse(content, Mode::Module, "") {
298 Ok(ast) => ast,
299 Err(e) => {
300 warn!("Failed to parse {:?}: {:?}", file_path, e);
301 return;
302 }
303 };
304
305 self.usages.remove(&file_path);
307
308 self.undeclared_fixtures.remove(&file_path);
310
311 self.imports.remove(&file_path);
313
314 for mut entry in self.definitions.iter_mut() {
317 entry.value_mut().retain(|def| def.file_path != file_path);
318 }
319 self.definitions.retain(|_, defs| !defs.is_empty());
321
322 let is_conftest = file_path
324 .file_name()
325 .map(|n| n == "conftest.py")
326 .unwrap_or(false);
327 debug!("is_conftest: {}", is_conftest);
328
329 let line_index = Self::build_line_index(content);
331
332 if let rustpython_parser::ast::Mod::Module(module) = parsed {
334 debug!("Module has {} statements", module.body.len());
335
336 let mut module_level_names = std::collections::HashSet::new();
338 for stmt in &module.body {
339 self.collect_module_level_names(stmt, &mut module_level_names);
340 }
341 self.imports.insert(file_path.clone(), module_level_names);
342
343 for stmt in &module.body {
345 self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
346 }
347 }
348
349 debug!("Analysis complete for {:?}", file_path);
350 }
351
352 fn visit_stmt(
353 &self,
354 stmt: &Stmt,
355 file_path: &PathBuf,
356 _is_conftest: bool,
357 content: &str,
358 line_index: &[usize],
359 ) {
360 if let Stmt::Assign(assign) = stmt {
362 self.visit_assignment_fixture(assign, file_path, content, line_index);
363 }
364
365 let (func_name, decorator_list, args, range, body, returns) = match stmt {
367 Stmt::FunctionDef(func_def) => (
368 func_def.name.as_str(),
369 &func_def.decorator_list,
370 &func_def.args,
371 func_def.range,
372 &func_def.body,
373 &func_def.returns,
374 ),
375 Stmt::AsyncFunctionDef(func_def) => (
376 func_def.name.as_str(),
377 &func_def.decorator_list,
378 &func_def.args,
379 func_def.range,
380 &func_def.body,
381 &func_def.returns,
382 ),
383 _ => return,
384 };
385
386 debug!("Found function: {}", func_name);
387
388 debug!(
390 "Function {} has {} decorators",
391 func_name,
392 decorator_list.len()
393 );
394 let is_fixture = decorator_list.iter().any(|dec| {
395 let result = Self::is_fixture_decorator(dec);
396 if result {
397 debug!(" Decorator matched as fixture!");
398 }
399 result
400 });
401
402 if is_fixture {
403 let line = self.get_line_from_offset(range.start().to_usize(), line_index);
405
406 let docstring = self.extract_docstring(body);
408
409 let return_type = self.extract_return_type(returns, body, content);
411
412 info!(
413 "Found fixture definition: {} at {:?}:{}",
414 func_name, file_path, line
415 );
416 if let Some(ref doc) = docstring {
417 debug!(" Docstring: {}", doc);
418 }
419 if let Some(ref ret_type) = return_type {
420 debug!(" Return type: {}", ret_type);
421 }
422
423 let definition = FixtureDefinition {
424 name: func_name.to_string(),
425 file_path: file_path.clone(),
426 line,
427 docstring,
428 return_type,
429 };
430
431 self.definitions
432 .entry(func_name.to_string())
433 .or_default()
434 .push(definition);
435
436 let mut declared_params: std::collections::HashSet<String> =
438 std::collections::HashSet::new();
439 declared_params.insert("self".to_string());
440 declared_params.insert("request".to_string());
441 declared_params.insert(func_name.to_string()); for arg in &args.args {
444 let arg_name = arg.def.arg.as_str();
445 declared_params.insert(arg_name.to_string());
446
447 if arg_name != "self" && arg_name != "request" {
448 let arg_line =
451 self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
452 let start_char = self.get_char_position_from_offset(
453 arg.def.range.start().to_usize(),
454 line_index,
455 );
456 let end_char = self
457 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
458
459 info!(
460 "Found fixture dependency: {} at {:?}:{}:{}",
461 arg_name, file_path, arg_line, start_char
462 );
463
464 let usage = FixtureUsage {
465 name: arg_name.to_string(),
466 file_path: file_path.clone(),
467 line: arg_line, start_char,
469 end_char,
470 };
471
472 self.usages
473 .entry(file_path.clone())
474 .or_default()
475 .push(usage);
476 }
477 }
478
479 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
481 self.scan_function_body_for_undeclared_fixtures(
482 body,
483 file_path,
484 content,
485 line_index,
486 &declared_params,
487 func_name,
488 function_line,
489 );
490 }
491
492 let is_test = func_name.starts_with("test_");
494
495 if is_test {
496 debug!("Found test function: {}", func_name);
497
498 let mut declared_params: std::collections::HashSet<String> =
500 std::collections::HashSet::new();
501 declared_params.insert("self".to_string());
502 declared_params.insert("request".to_string()); for arg in &args.args {
506 let arg_name = arg.def.arg.as_str();
507 declared_params.insert(arg_name.to_string());
508
509 if arg_name != "self" {
510 let arg_offset = arg.def.range.start().to_usize();
514 let arg_line = self.get_line_from_offset(arg_offset, line_index);
515 let start_char = self.get_char_position_from_offset(arg_offset, line_index);
516 let end_char = self
517 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
518
519 debug!(
520 "Parameter {} at offset {}, calculated line {}, char {}",
521 arg_name, arg_offset, arg_line, start_char
522 );
523 info!(
524 "Found fixture usage: {} at {:?}:{}:{}",
525 arg_name, file_path, arg_line, start_char
526 );
527
528 let usage = FixtureUsage {
529 name: arg_name.to_string(),
530 file_path: file_path.clone(),
531 line: arg_line, start_char,
533 end_char,
534 };
535
536 self.usages
538 .entry(file_path.clone())
539 .or_default()
540 .push(usage);
541 }
542 }
543
544 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
546 self.scan_function_body_for_undeclared_fixtures(
547 body,
548 file_path,
549 content,
550 line_index,
551 &declared_params,
552 func_name,
553 function_line,
554 );
555 }
556 }
557
558 fn visit_assignment_fixture(
559 &self,
560 assign: &rustpython_parser::ast::StmtAssign,
561 file_path: &PathBuf,
562 _content: &str,
563 line_index: &[usize],
564 ) {
565 if let Expr::Call(outer_call) = &*assign.value {
569 if let Expr::Call(inner_call) = &*outer_call.func {
571 if Self::is_fixture_decorator(&inner_call.func) {
572 for target in &assign.targets {
575 if let Expr::Name(name) = target {
576 let fixture_name = name.id.as_str();
577 let line = self
578 .get_line_from_offset(assign.range.start().to_usize(), line_index);
579
580 info!(
581 "Found fixture assignment: {} at {:?}:{}",
582 fixture_name, file_path, line
583 );
584
585 let definition = FixtureDefinition {
587 name: fixture_name.to_string(),
588 file_path: file_path.clone(),
589 line,
590 docstring: None,
591 return_type: None,
592 };
593
594 self.definitions
595 .entry(fixture_name.to_string())
596 .or_default()
597 .push(definition);
598 }
599 }
600 }
601 }
602 }
603 }
604
605 fn is_fixture_decorator(expr: &Expr) -> bool {
606 match expr {
607 Expr::Name(name) => name.id.as_str() == "fixture",
608 Expr::Attribute(attr) => {
609 if let Expr::Name(value) = &*attr.value {
611 value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
612 } else {
613 false
614 }
615 }
616 Expr::Call(call) => {
617 Self::is_fixture_decorator(&call.func)
619 }
620 _ => false,
621 }
622 }
623
624 #[allow(clippy::too_many_arguments)]
625 fn scan_function_body_for_undeclared_fixtures(
626 &self,
627 body: &[Stmt],
628 file_path: &PathBuf,
629 content: &str,
630 line_index: &[usize],
631 declared_params: &std::collections::HashSet<String>,
632 function_name: &str,
633 function_line: usize,
634 ) {
635 let mut local_vars = std::collections::HashMap::new();
637 self.collect_local_variables(body, content, line_index, &mut local_vars);
638
639 if let Some(imports) = self.imports.get(file_path) {
642 for import in imports.iter() {
643 local_vars.insert(import.clone(), 0);
644 }
645 }
646
647 for stmt in body {
649 self.visit_stmt_for_names(
650 stmt,
651 file_path,
652 content,
653 line_index,
654 declared_params,
655 &local_vars,
656 function_name,
657 function_line,
658 );
659 }
660 }
661
662 fn collect_module_level_names(
663 &self,
664 stmt: &Stmt,
665 names: &mut std::collections::HashSet<String>,
666 ) {
667 match stmt {
668 Stmt::Import(import_stmt) => {
670 for alias in &import_stmt.names {
671 let name = alias.asname.as_ref().unwrap_or(&alias.name);
673 names.insert(name.to_string());
674 }
675 }
676 Stmt::ImportFrom(import_from) => {
677 for alias in &import_from.names {
678 let name = alias.asname.as_ref().unwrap_or(&alias.name);
680 names.insert(name.to_string());
681 }
682 }
683 Stmt::FunctionDef(func_def) => {
685 let is_fixture = func_def
687 .decorator_list
688 .iter()
689 .any(Self::is_fixture_decorator);
690 if !is_fixture {
691 names.insert(func_def.name.to_string());
692 }
693 }
694 Stmt::AsyncFunctionDef(func_def) => {
696 let is_fixture = func_def
697 .decorator_list
698 .iter()
699 .any(Self::is_fixture_decorator);
700 if !is_fixture {
701 names.insert(func_def.name.to_string());
702 }
703 }
704 Stmt::ClassDef(class_def) => {
706 names.insert(class_def.name.to_string());
707 }
708 Stmt::Assign(assign) => {
710 for target in &assign.targets {
711 self.collect_names_from_expr(target, names);
712 }
713 }
714 Stmt::AnnAssign(ann_assign) => {
715 self.collect_names_from_expr(&ann_assign.target, names);
716 }
717 _ => {}
718 }
719 }
720
721 #[allow(clippy::only_used_in_recursion)]
722 fn collect_local_variables(
723 &self,
724 body: &[Stmt],
725 content: &str,
726 line_index: &[usize],
727 local_vars: &mut std::collections::HashMap<String, usize>,
728 ) {
729 for stmt in body {
730 match stmt {
731 Stmt::Assign(assign) => {
732 let line =
734 self.get_line_from_offset(assign.range.start().to_usize(), line_index);
735 let mut temp_names = std::collections::HashSet::new();
736 for target in &assign.targets {
737 self.collect_names_from_expr(target, &mut temp_names);
738 }
739 for name in temp_names {
740 local_vars.insert(name, line);
741 }
742 }
743 Stmt::AnnAssign(ann_assign) => {
744 let line =
746 self.get_line_from_offset(ann_assign.range.start().to_usize(), line_index);
747 let mut temp_names = std::collections::HashSet::new();
748 self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
749 for name in temp_names {
750 local_vars.insert(name, line);
751 }
752 }
753 Stmt::AugAssign(aug_assign) => {
754 let line =
756 self.get_line_from_offset(aug_assign.range.start().to_usize(), line_index);
757 let mut temp_names = std::collections::HashSet::new();
758 self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
759 for name in temp_names {
760 local_vars.insert(name, line);
761 }
762 }
763 Stmt::For(for_stmt) => {
764 let line =
766 self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
767 let mut temp_names = std::collections::HashSet::new();
768 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
769 for name in temp_names {
770 local_vars.insert(name, line);
771 }
772 self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
774 }
775 Stmt::AsyncFor(for_stmt) => {
776 let line =
777 self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
778 let mut temp_names = std::collections::HashSet::new();
779 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
780 for name in temp_names {
781 local_vars.insert(name, line);
782 }
783 self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
784 }
785 Stmt::While(while_stmt) => {
786 self.collect_local_variables(&while_stmt.body, content, line_index, local_vars);
787 }
788 Stmt::If(if_stmt) => {
789 self.collect_local_variables(&if_stmt.body, content, line_index, local_vars);
790 self.collect_local_variables(&if_stmt.orelse, content, line_index, local_vars);
791 }
792 Stmt::With(with_stmt) => {
793 let line =
795 self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
796 for item in &with_stmt.items {
797 if let Some(ref optional_vars) = item.optional_vars {
798 let mut temp_names = std::collections::HashSet::new();
799 self.collect_names_from_expr(optional_vars, &mut temp_names);
800 for name in temp_names {
801 local_vars.insert(name, line);
802 }
803 }
804 }
805 self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
806 }
807 Stmt::AsyncWith(with_stmt) => {
808 let line =
809 self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
810 for item in &with_stmt.items {
811 if let Some(ref optional_vars) = item.optional_vars {
812 let mut temp_names = std::collections::HashSet::new();
813 self.collect_names_from_expr(optional_vars, &mut temp_names);
814 for name in temp_names {
815 local_vars.insert(name, line);
816 }
817 }
818 }
819 self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
820 }
821 Stmt::Try(try_stmt) => {
822 self.collect_local_variables(&try_stmt.body, content, line_index, local_vars);
823 self.collect_local_variables(&try_stmt.orelse, content, line_index, local_vars);
826 self.collect_local_variables(
827 &try_stmt.finalbody,
828 content,
829 line_index,
830 local_vars,
831 );
832 }
833 _ => {}
834 }
835 }
836 }
837
838 #[allow(clippy::only_used_in_recursion)]
839 fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
840 match expr {
841 Expr::Name(name) => {
842 names.insert(name.id.to_string());
843 }
844 Expr::Tuple(tuple) => {
845 for elt in &tuple.elts {
846 self.collect_names_from_expr(elt, names);
847 }
848 }
849 Expr::List(list) => {
850 for elt in &list.elts {
851 self.collect_names_from_expr(elt, names);
852 }
853 }
854 _ => {}
855 }
856 }
857
858 #[allow(clippy::too_many_arguments)]
859 fn visit_stmt_for_names(
860 &self,
861 stmt: &Stmt,
862 file_path: &PathBuf,
863 content: &str,
864 line_index: &[usize],
865 declared_params: &std::collections::HashSet<String>,
866 local_vars: &std::collections::HashMap<String, usize>,
867 function_name: &str,
868 function_line: usize,
869 ) {
870 match stmt {
871 Stmt::Expr(expr_stmt) => {
872 self.visit_expr_for_names(
873 &expr_stmt.value,
874 file_path,
875 content,
876 line_index,
877 declared_params,
878 local_vars,
879 function_name,
880 function_line,
881 );
882 }
883 Stmt::Assign(assign) => {
884 self.visit_expr_for_names(
885 &assign.value,
886 file_path,
887 content,
888 line_index,
889 declared_params,
890 local_vars,
891 function_name,
892 function_line,
893 );
894 }
895 Stmt::AugAssign(aug_assign) => {
896 self.visit_expr_for_names(
897 &aug_assign.value,
898 file_path,
899 content,
900 line_index,
901 declared_params,
902 local_vars,
903 function_name,
904 function_line,
905 );
906 }
907 Stmt::Return(ret) => {
908 if let Some(ref value) = ret.value {
909 self.visit_expr_for_names(
910 value,
911 file_path,
912 content,
913 line_index,
914 declared_params,
915 local_vars,
916 function_name,
917 function_line,
918 );
919 }
920 }
921 Stmt::If(if_stmt) => {
922 self.visit_expr_for_names(
923 &if_stmt.test,
924 file_path,
925 content,
926 line_index,
927 declared_params,
928 local_vars,
929 function_name,
930 function_line,
931 );
932 for stmt in &if_stmt.body {
933 self.visit_stmt_for_names(
934 stmt,
935 file_path,
936 content,
937 line_index,
938 declared_params,
939 local_vars,
940 function_name,
941 function_line,
942 );
943 }
944 for stmt in &if_stmt.orelse {
945 self.visit_stmt_for_names(
946 stmt,
947 file_path,
948 content,
949 line_index,
950 declared_params,
951 local_vars,
952 function_name,
953 function_line,
954 );
955 }
956 }
957 Stmt::While(while_stmt) => {
958 self.visit_expr_for_names(
959 &while_stmt.test,
960 file_path,
961 content,
962 line_index,
963 declared_params,
964 local_vars,
965 function_name,
966 function_line,
967 );
968 for stmt in &while_stmt.body {
969 self.visit_stmt_for_names(
970 stmt,
971 file_path,
972 content,
973 line_index,
974 declared_params,
975 local_vars,
976 function_name,
977 function_line,
978 );
979 }
980 }
981 Stmt::For(for_stmt) => {
982 self.visit_expr_for_names(
983 &for_stmt.iter,
984 file_path,
985 content,
986 line_index,
987 declared_params,
988 local_vars,
989 function_name,
990 function_line,
991 );
992 for stmt in &for_stmt.body {
993 self.visit_stmt_for_names(
994 stmt,
995 file_path,
996 content,
997 line_index,
998 declared_params,
999 local_vars,
1000 function_name,
1001 function_line,
1002 );
1003 }
1004 }
1005 Stmt::With(with_stmt) => {
1006 for item in &with_stmt.items {
1007 self.visit_expr_for_names(
1008 &item.context_expr,
1009 file_path,
1010 content,
1011 line_index,
1012 declared_params,
1013 local_vars,
1014 function_name,
1015 function_line,
1016 );
1017 }
1018 for stmt in &with_stmt.body {
1019 self.visit_stmt_for_names(
1020 stmt,
1021 file_path,
1022 content,
1023 line_index,
1024 declared_params,
1025 local_vars,
1026 function_name,
1027 function_line,
1028 );
1029 }
1030 }
1031 Stmt::AsyncFor(for_stmt) => {
1032 self.visit_expr_for_names(
1033 &for_stmt.iter,
1034 file_path,
1035 content,
1036 line_index,
1037 declared_params,
1038 local_vars,
1039 function_name,
1040 function_line,
1041 );
1042 for stmt in &for_stmt.body {
1043 self.visit_stmt_for_names(
1044 stmt,
1045 file_path,
1046 content,
1047 line_index,
1048 declared_params,
1049 local_vars,
1050 function_name,
1051 function_line,
1052 );
1053 }
1054 }
1055 Stmt::AsyncWith(with_stmt) => {
1056 for item in &with_stmt.items {
1057 self.visit_expr_for_names(
1058 &item.context_expr,
1059 file_path,
1060 content,
1061 line_index,
1062 declared_params,
1063 local_vars,
1064 function_name,
1065 function_line,
1066 );
1067 }
1068 for stmt in &with_stmt.body {
1069 self.visit_stmt_for_names(
1070 stmt,
1071 file_path,
1072 content,
1073 line_index,
1074 declared_params,
1075 local_vars,
1076 function_name,
1077 function_line,
1078 );
1079 }
1080 }
1081 Stmt::Assert(assert_stmt) => {
1082 self.visit_expr_for_names(
1083 &assert_stmt.test,
1084 file_path,
1085 content,
1086 line_index,
1087 declared_params,
1088 local_vars,
1089 function_name,
1090 function_line,
1091 );
1092 if let Some(ref msg) = assert_stmt.msg {
1093 self.visit_expr_for_names(
1094 msg,
1095 file_path,
1096 content,
1097 line_index,
1098 declared_params,
1099 local_vars,
1100 function_name,
1101 function_line,
1102 );
1103 }
1104 }
1105 _ => {} }
1107 }
1108
1109 #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
1110 fn visit_expr_for_names(
1111 &self,
1112 expr: &Expr,
1113 file_path: &PathBuf,
1114 content: &str,
1115 line_index: &[usize],
1116 declared_params: &std::collections::HashSet<String>,
1117 local_vars: &std::collections::HashMap<String, usize>,
1118 function_name: &str,
1119 function_line: usize,
1120 ) {
1121 match expr {
1122 Expr::Name(name) => {
1123 let name_str = name.id.as_str();
1124 let line = self.get_line_from_offset(name.range.start().to_usize(), line_index);
1125
1126 let is_local_var_in_scope = local_vars
1130 .get(name_str)
1131 .map(|def_line| *def_line < line)
1132 .unwrap_or(false);
1133
1134 if !declared_params.contains(name_str)
1135 && !is_local_var_in_scope
1136 && self.is_available_fixture(file_path, name_str)
1137 {
1138 let start_char = self
1139 .get_char_position_from_offset(name.range.start().to_usize(), line_index);
1140 let end_char =
1141 self.get_char_position_from_offset(name.range.end().to_usize(), line_index);
1142
1143 info!(
1144 "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1145 name_str, file_path, line, start_char, function_name
1146 );
1147
1148 let undeclared = UndeclaredFixture {
1149 name: name_str.to_string(),
1150 file_path: file_path.clone(),
1151 line,
1152 start_char,
1153 end_char,
1154 function_name: function_name.to_string(),
1155 function_line,
1156 };
1157
1158 self.undeclared_fixtures
1159 .entry(file_path.clone())
1160 .or_default()
1161 .push(undeclared);
1162 }
1163 }
1164 Expr::Call(call) => {
1165 self.visit_expr_for_names(
1166 &call.func,
1167 file_path,
1168 content,
1169 line_index,
1170 declared_params,
1171 local_vars,
1172 function_name,
1173 function_line,
1174 );
1175 for arg in &call.args {
1176 self.visit_expr_for_names(
1177 arg,
1178 file_path,
1179 content,
1180 line_index,
1181 declared_params,
1182 local_vars,
1183 function_name,
1184 function_line,
1185 );
1186 }
1187 }
1188 Expr::Attribute(attr) => {
1189 self.visit_expr_for_names(
1190 &attr.value,
1191 file_path,
1192 content,
1193 line_index,
1194 declared_params,
1195 local_vars,
1196 function_name,
1197 function_line,
1198 );
1199 }
1200 Expr::BinOp(binop) => {
1201 self.visit_expr_for_names(
1202 &binop.left,
1203 file_path,
1204 content,
1205 line_index,
1206 declared_params,
1207 local_vars,
1208 function_name,
1209 function_line,
1210 );
1211 self.visit_expr_for_names(
1212 &binop.right,
1213 file_path,
1214 content,
1215 line_index,
1216 declared_params,
1217 local_vars,
1218 function_name,
1219 function_line,
1220 );
1221 }
1222 Expr::UnaryOp(unaryop) => {
1223 self.visit_expr_for_names(
1224 &unaryop.operand,
1225 file_path,
1226 content,
1227 line_index,
1228 declared_params,
1229 local_vars,
1230 function_name,
1231 function_line,
1232 );
1233 }
1234 Expr::Compare(compare) => {
1235 self.visit_expr_for_names(
1236 &compare.left,
1237 file_path,
1238 content,
1239 line_index,
1240 declared_params,
1241 local_vars,
1242 function_name,
1243 function_line,
1244 );
1245 for comparator in &compare.comparators {
1246 self.visit_expr_for_names(
1247 comparator,
1248 file_path,
1249 content,
1250 line_index,
1251 declared_params,
1252 local_vars,
1253 function_name,
1254 function_line,
1255 );
1256 }
1257 }
1258 Expr::Subscript(subscript) => {
1259 self.visit_expr_for_names(
1260 &subscript.value,
1261 file_path,
1262 content,
1263 line_index,
1264 declared_params,
1265 local_vars,
1266 function_name,
1267 function_line,
1268 );
1269 self.visit_expr_for_names(
1270 &subscript.slice,
1271 file_path,
1272 content,
1273 line_index,
1274 declared_params,
1275 local_vars,
1276 function_name,
1277 function_line,
1278 );
1279 }
1280 Expr::List(list) => {
1281 for elt in &list.elts {
1282 self.visit_expr_for_names(
1283 elt,
1284 file_path,
1285 content,
1286 line_index,
1287 declared_params,
1288 local_vars,
1289 function_name,
1290 function_line,
1291 );
1292 }
1293 }
1294 Expr::Tuple(tuple) => {
1295 for elt in &tuple.elts {
1296 self.visit_expr_for_names(
1297 elt,
1298 file_path,
1299 content,
1300 line_index,
1301 declared_params,
1302 local_vars,
1303 function_name,
1304 function_line,
1305 );
1306 }
1307 }
1308 Expr::Dict(dict) => {
1309 for k in dict.keys.iter().flatten() {
1310 self.visit_expr_for_names(
1311 k,
1312 file_path,
1313 content,
1314 line_index,
1315 declared_params,
1316 local_vars,
1317 function_name,
1318 function_line,
1319 );
1320 }
1321 for value in &dict.values {
1322 self.visit_expr_for_names(
1323 value,
1324 file_path,
1325 content,
1326 line_index,
1327 declared_params,
1328 local_vars,
1329 function_name,
1330 function_line,
1331 );
1332 }
1333 }
1334 Expr::Await(await_expr) => {
1335 self.visit_expr_for_names(
1337 &await_expr.value,
1338 file_path,
1339 content,
1340 line_index,
1341 declared_params,
1342 local_vars,
1343 function_name,
1344 function_line,
1345 );
1346 }
1347 _ => {} }
1349 }
1350
1351 fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1352 if let Some(definitions) = self.definitions.get(fixture_name) {
1354 for def in definitions.iter() {
1356 if def.file_path == file_path {
1358 return true;
1359 }
1360
1361 if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1363 && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1364 {
1365 return true;
1366 }
1367
1368 if def.file_path.to_string_lossy().contains("site-packages") {
1370 return true;
1371 }
1372 }
1373 }
1374 false
1375 }
1376
1377 fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1378 if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1380 if let Expr::Constant(constant) = &*expr_stmt.value {
1381 if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1383 return Some(self.format_docstring(s.to_string()));
1384 }
1385 }
1386 }
1387 None
1388 }
1389
1390 fn format_docstring(&self, docstring: String) -> String {
1391 let lines: Vec<&str> = docstring.lines().collect();
1394
1395 if lines.is_empty() {
1396 return String::new();
1397 }
1398
1399 let mut start = 0;
1401 let mut end = lines.len();
1402
1403 while start < lines.len() && lines[start].trim().is_empty() {
1404 start += 1;
1405 }
1406
1407 while end > start && lines[end - 1].trim().is_empty() {
1408 end -= 1;
1409 }
1410
1411 if start >= end {
1412 return String::new();
1413 }
1414
1415 let lines = &lines[start..end];
1416
1417 let mut min_indent = usize::MAX;
1419 for (i, line) in lines.iter().enumerate() {
1420 if i == 0 && !line.trim().is_empty() {
1421 continue;
1423 }
1424
1425 if !line.trim().is_empty() {
1426 let indent = line.len() - line.trim_start().len();
1427 min_indent = min_indent.min(indent);
1428 }
1429 }
1430
1431 if min_indent == usize::MAX {
1432 min_indent = 0;
1433 }
1434
1435 let mut result = Vec::new();
1437 for (i, line) in lines.iter().enumerate() {
1438 if i == 0 {
1439 result.push(line.trim().to_string());
1441 } else if line.trim().is_empty() {
1442 result.push(String::new());
1444 } else {
1445 let dedented = if line.len() > min_indent {
1447 &line[min_indent..]
1448 } else {
1449 line.trim_start()
1450 };
1451 result.push(dedented.to_string());
1452 }
1453 }
1454
1455 result.join("\n")
1457 }
1458
1459 fn extract_return_type(
1460 &self,
1461 returns: &Option<Box<rustpython_parser::ast::Expr>>,
1462 body: &[Stmt],
1463 content: &str,
1464 ) -> Option<String> {
1465 if let Some(return_expr) = returns {
1466 let has_yield = self.contains_yield(body);
1468
1469 if has_yield {
1470 return self.extract_yielded_type(return_expr, content);
1473 } else {
1474 return Some(self.expr_to_string(return_expr, content));
1476 }
1477 }
1478 None
1479 }
1480
1481 #[allow(clippy::only_used_in_recursion)]
1482 fn contains_yield(&self, body: &[Stmt]) -> bool {
1483 for stmt in body {
1484 match stmt {
1485 Stmt::Expr(expr_stmt) => {
1486 if let Expr::Yield(_) | Expr::YieldFrom(_) = &*expr_stmt.value {
1487 return true;
1488 }
1489 }
1490 Stmt::If(if_stmt) => {
1491 if self.contains_yield(&if_stmt.body) || self.contains_yield(&if_stmt.orelse) {
1492 return true;
1493 }
1494 }
1495 Stmt::For(for_stmt) => {
1496 if self.contains_yield(&for_stmt.body) || self.contains_yield(&for_stmt.orelse)
1497 {
1498 return true;
1499 }
1500 }
1501 Stmt::While(while_stmt) => {
1502 if self.contains_yield(&while_stmt.body)
1503 || self.contains_yield(&while_stmt.orelse)
1504 {
1505 return true;
1506 }
1507 }
1508 Stmt::With(with_stmt) => {
1509 if self.contains_yield(&with_stmt.body) {
1510 return true;
1511 }
1512 }
1513 Stmt::Try(try_stmt) => {
1514 if self.contains_yield(&try_stmt.body)
1515 || self.contains_yield(&try_stmt.orelse)
1516 || self.contains_yield(&try_stmt.finalbody)
1517 {
1518 return true;
1519 }
1520 }
1523 _ => {}
1524 }
1525 }
1526 false
1527 }
1528
1529 fn extract_yielded_type(
1530 &self,
1531 expr: &rustpython_parser::ast::Expr,
1532 content: &str,
1533 ) -> Option<String> {
1534 if let Expr::Subscript(subscript) = expr {
1538 let _base_name = self.expr_to_string(&subscript.value, content);
1540
1541 if let Expr::Tuple(tuple) = &*subscript.slice {
1543 if let Some(first_elem) = tuple.elts.first() {
1544 return Some(self.expr_to_string(first_elem, content));
1545 }
1546 } else {
1547 return Some(self.expr_to_string(&subscript.slice, content));
1549 }
1550 }
1551
1552 Some(self.expr_to_string(expr, content))
1554 }
1555
1556 #[allow(clippy::only_used_in_recursion)]
1557 fn expr_to_string(&self, expr: &rustpython_parser::ast::Expr, _content: &str) -> String {
1558 match expr {
1559 Expr::Name(name) => name.id.to_string(),
1560 Expr::Attribute(attr) => {
1561 format!(
1562 "{}.{}",
1563 self.expr_to_string(&attr.value, _content),
1564 attr.attr
1565 )
1566 }
1567 Expr::Subscript(subscript) => {
1568 let base = self.expr_to_string(&subscript.value, _content);
1569 let slice = self.expr_to_string(&subscript.slice, _content);
1570 format!("{}[{}]", base, slice)
1571 }
1572 Expr::Tuple(tuple) => {
1573 let elements: Vec<String> = tuple
1574 .elts
1575 .iter()
1576 .map(|e| self.expr_to_string(e, _content))
1577 .collect();
1578 elements.join(", ")
1579 }
1580 Expr::Constant(constant) => {
1581 format!("{:?}", constant.value)
1582 }
1583 Expr::BinOp(binop) if matches!(binop.op, rustpython_parser::ast::Operator::BitOr) => {
1584 format!(
1586 "{} | {}",
1587 self.expr_to_string(&binop.left, _content),
1588 self.expr_to_string(&binop.right, _content)
1589 )
1590 }
1591 _ => {
1592 "Any".to_string()
1594 }
1595 }
1596 }
1597
1598 fn build_line_index(content: &str) -> Vec<usize> {
1599 let mut line_index = Vec::with_capacity(content.len() / 30);
1600 line_index.push(0);
1601 for (i, c) in content.char_indices() {
1602 if c == '\n' {
1603 line_index.push(i + 1);
1604 }
1605 }
1606 line_index
1607 }
1608
1609 fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
1610 match line_index.binary_search(&offset) {
1611 Ok(line) => line + 1,
1612 Err(line) => line,
1613 }
1614 }
1615
1616 fn get_char_position_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
1617 let line = self.get_line_from_offset(offset, line_index);
1618 let line_start = line_index[line - 1];
1619 offset.saturating_sub(line_start)
1620 }
1621
1622 pub fn find_fixture_definition(
1624 &self,
1625 file_path: &Path,
1626 line: u32,
1627 character: u32,
1628 ) -> Option<FixtureDefinition> {
1629 debug!(
1630 "find_fixture_definition: file={:?}, line={}, char={}",
1631 file_path, line, character
1632 );
1633
1634 let target_line = (line + 1) as usize; let content = self.get_file_content(file_path)?;
1639
1640 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1642 debug!("Line content: {}", line_content);
1643
1644 let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1646 debug!("Word at cursor: {:?}", word_at_cursor);
1647
1648 let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1651
1652 if let Some(usages) = self.usages.get(file_path) {
1655 for usage in usages.iter() {
1656 if usage.line == target_line && usage.name == word_at_cursor {
1657 let cursor_pos = character as usize;
1659 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1660 debug!(
1661 "Cursor at {} is within usage range {}-{}: {}",
1662 cursor_pos, usage.start_char, usage.end_char, usage.name
1663 );
1664 info!("Found fixture usage at cursor position: {}", usage.name);
1665
1666 if let Some(ref current_def) = current_fixture_def {
1668 if current_def.name == word_at_cursor {
1669 info!(
1670 "Self-referencing fixture detected, finding parent definition"
1671 );
1672 return self.find_closest_definition_excluding(
1673 file_path,
1674 &usage.name,
1675 Some(current_def),
1676 );
1677 }
1678 }
1679
1680 return self.find_closest_definition(file_path, &usage.name);
1682 }
1683 }
1684 }
1685 }
1686
1687 debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1688 None
1689 }
1690
1691 fn get_fixture_definition_at_line(
1693 &self,
1694 file_path: &Path,
1695 line: usize,
1696 ) -> Option<FixtureDefinition> {
1697 for entry in self.definitions.iter() {
1698 for def in entry.value().iter() {
1699 if def.file_path == file_path && def.line == line {
1700 return Some(def.clone());
1701 }
1702 }
1703 }
1704 None
1705 }
1706
1707 pub fn get_definition_at_line(
1710 &self,
1711 file_path: &Path,
1712 line: usize,
1713 fixture_name: &str,
1714 ) -> Option<FixtureDefinition> {
1715 if let Some(definitions) = self.definitions.get(fixture_name) {
1716 for def in definitions.iter() {
1717 if def.file_path == file_path && def.line == line {
1718 return Some(def.clone());
1719 }
1720 }
1721 }
1722 None
1723 }
1724
1725 fn find_closest_definition(
1726 &self,
1727 file_path: &Path,
1728 fixture_name: &str,
1729 ) -> Option<FixtureDefinition> {
1730 let definitions = self.definitions.get(fixture_name)?;
1731
1732 debug!(
1735 "Checking for fixture {} in same file: {:?}",
1736 fixture_name, file_path
1737 );
1738
1739 if let Some(last_def) = definitions
1741 .iter()
1742 .filter(|def| def.file_path == file_path)
1743 .max_by_key(|def| def.line)
1744 {
1745 info!(
1746 "Found fixture {} in same file at line {} (using last definition)",
1747 fixture_name, last_def.line
1748 );
1749 return Some(last_def.clone());
1750 }
1751
1752 let mut current_dir = file_path.parent()?;
1755
1756 debug!(
1757 "Searching for fixture {} in conftest.py files starting from {:?}",
1758 fixture_name, current_dir
1759 );
1760 loop {
1761 let conftest_path = current_dir.join("conftest.py");
1763 debug!(" Checking conftest.py at: {:?}", conftest_path);
1764
1765 for def in definitions.iter() {
1766 if def.file_path == conftest_path {
1767 info!(
1768 "Found fixture {} in conftest.py: {:?}",
1769 fixture_name, conftest_path
1770 );
1771 return Some(def.clone());
1772 }
1773 }
1774
1775 match current_dir.parent() {
1777 Some(parent) => current_dir = parent,
1778 None => break,
1779 }
1780 }
1781
1782 debug!(
1785 "No fixture {} found in conftest hierarchy, checking for third-party fixtures",
1786 fixture_name
1787 );
1788 for def in definitions.iter() {
1789 if def.file_path.to_string_lossy().contains("site-packages") {
1790 info!(
1791 "Found third-party fixture {} in site-packages: {:?}",
1792 fixture_name, def.file_path
1793 );
1794 return Some(def.clone());
1795 }
1796 }
1797
1798 warn!(
1803 "No fixture {} found following priority rules (same file, conftest hierarchy, third-party)",
1804 fixture_name
1805 );
1806 warn!(
1807 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1808 );
1809
1810 let mut defs: Vec<_> = definitions.iter().cloned().collect();
1811 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1812 defs.first().cloned()
1813 }
1814
1815 fn find_closest_definition_excluding(
1818 &self,
1819 file_path: &Path,
1820 fixture_name: &str,
1821 exclude: Option<&FixtureDefinition>,
1822 ) -> Option<FixtureDefinition> {
1823 let definitions = self.definitions.get(fixture_name)?;
1824
1825 debug!(
1829 "Checking for fixture {} in same file: {:?} (excluding: {:?})",
1830 fixture_name, file_path, exclude
1831 );
1832
1833 if let Some(last_def) = definitions
1835 .iter()
1836 .filter(|def| {
1837 if def.file_path != file_path {
1838 return false;
1839 }
1840 if let Some(excluded) = exclude {
1842 if def == &excluded {
1843 debug!("Skipping excluded definition at line {}", def.line);
1844 return false;
1845 }
1846 }
1847 true
1848 })
1849 .max_by_key(|def| def.line)
1850 {
1851 info!(
1852 "Found fixture {} in same file at line {} (using last definition, excluding specified)",
1853 fixture_name, last_def.line
1854 );
1855 return Some(last_def.clone());
1856 }
1857
1858 let mut current_dir = file_path.parent()?;
1860
1861 debug!(
1862 "Searching for fixture {} in conftest.py files starting from {:?}",
1863 fixture_name, current_dir
1864 );
1865 loop {
1866 let conftest_path = current_dir.join("conftest.py");
1867 debug!(" Checking conftest.py at: {:?}", conftest_path);
1868
1869 for def in definitions.iter() {
1870 if def.file_path == conftest_path {
1871 if let Some(excluded) = exclude {
1873 if def == excluded {
1874 debug!("Skipping excluded definition at line {}", def.line);
1875 continue;
1876 }
1877 }
1878 info!(
1879 "Found fixture {} in conftest.py: {:?}",
1880 fixture_name, conftest_path
1881 );
1882 return Some(def.clone());
1883 }
1884 }
1885
1886 match current_dir.parent() {
1888 Some(parent) => current_dir = parent,
1889 None => break,
1890 }
1891 }
1892
1893 debug!(
1895 "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
1896 fixture_name
1897 );
1898 for def in definitions.iter() {
1899 if let Some(excluded) = exclude {
1901 if def == excluded {
1902 continue;
1903 }
1904 }
1905 if def.file_path.to_string_lossy().contains("site-packages") {
1906 info!(
1907 "Found third-party fixture {} in site-packages: {:?}",
1908 fixture_name, def.file_path
1909 );
1910 return Some(def.clone());
1911 }
1912 }
1913
1914 warn!(
1916 "No fixture {} found following priority rules (excluding specified)",
1917 fixture_name
1918 );
1919 warn!(
1920 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1921 );
1922
1923 let mut defs: Vec<_> = definitions
1924 .iter()
1925 .filter(|def| {
1926 if let Some(excluded) = exclude {
1927 def != &excluded
1928 } else {
1929 true
1930 }
1931 })
1932 .cloned()
1933 .collect();
1934 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1935 defs.first().cloned()
1936 }
1937
1938 pub fn find_fixture_at_position(
1940 &self,
1941 file_path: &Path,
1942 line: u32,
1943 character: u32,
1944 ) -> Option<String> {
1945 let target_line = (line + 1) as usize; debug!(
1948 "find_fixture_at_position: file={:?}, line={}, char={}",
1949 file_path, target_line, character
1950 );
1951
1952 let content = self.get_file_content(file_path)?;
1955
1956 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1958 debug!("Line content: {}", line_content);
1959
1960 let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
1962 debug!("Word at cursor: {:?}", word_at_cursor);
1963
1964 if let Some(usages) = self.usages.get(file_path) {
1967 for usage in usages.iter() {
1968 if usage.line == target_line {
1969 let cursor_pos = character as usize;
1971 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1972 debug!(
1973 "Cursor at {} is within usage range {}-{}: {}",
1974 cursor_pos, usage.start_char, usage.end_char, usage.name
1975 );
1976 info!("Found fixture usage at cursor position: {}", usage.name);
1977 return Some(usage.name.clone());
1978 }
1979 }
1980 }
1981 }
1982
1983 for entry in self.definitions.iter() {
1986 for def in entry.value().iter() {
1987 if def.file_path == file_path && def.line == target_line {
1988 if let Some(ref word) = word_at_cursor {
1990 if word == &def.name {
1991 info!(
1992 "Found fixture definition name at cursor position: {}",
1993 def.name
1994 );
1995 return Some(def.name.clone());
1996 }
1997 }
1998 }
2001 }
2002 }
2003
2004 debug!("No fixture found at cursor position");
2005 None
2006 }
2007
2008 pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
2009 let chars: Vec<char> = line.chars().collect();
2010
2011 if character > chars.len() {
2013 return None;
2014 }
2015
2016 if character < chars.len() {
2018 let c = chars[character];
2019 if c.is_alphanumeric() || c == '_' {
2020 let mut start = character;
2022 while start > 0 {
2023 let prev_c = chars[start - 1];
2024 if !prev_c.is_alphanumeric() && prev_c != '_' {
2025 break;
2026 }
2027 start -= 1;
2028 }
2029
2030 let mut end = character;
2031 while end < chars.len() {
2032 let curr_c = chars[end];
2033 if !curr_c.is_alphanumeric() && curr_c != '_' {
2034 break;
2035 }
2036 end += 1;
2037 }
2038
2039 if start < end {
2040 return Some(chars[start..end].iter().collect());
2041 }
2042 }
2043 }
2044
2045 None
2046 }
2047
2048 pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
2050 info!("Finding all references for fixture: {}", fixture_name);
2051
2052 let mut all_references = Vec::new();
2053
2054 for entry in self.usages.iter() {
2056 let file_path = entry.key();
2057 let usages = entry.value();
2058
2059 for usage in usages.iter() {
2061 if usage.name == fixture_name {
2062 debug!(
2063 "Found reference to {} in {:?} at line {}",
2064 fixture_name, file_path, usage.line
2065 );
2066 all_references.push(usage.clone());
2067 }
2068 }
2069 }
2070
2071 info!(
2072 "Found {} total references for fixture: {}",
2073 all_references.len(),
2074 fixture_name
2075 );
2076 all_references
2077 }
2078
2079 pub fn find_references_for_definition(
2086 &self,
2087 definition: &FixtureDefinition,
2088 ) -> Vec<FixtureUsage> {
2089 info!(
2090 "Finding references for specific definition: {} at {:?}:{}",
2091 definition.name, definition.file_path, definition.line
2092 );
2093
2094 let mut matching_references = Vec::new();
2095
2096 for entry in self.usages.iter() {
2098 let file_path = entry.key();
2099 let usages = entry.value();
2100
2101 for usage in usages.iter() {
2102 if usage.name == definition.name {
2103 let fixture_def_at_line =
2106 self.get_fixture_definition_at_line(file_path, usage.line);
2107
2108 let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
2109 if current_def.name == usage.name {
2110 debug!(
2112 "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
2113 file_path, usage.line, current_def.line
2114 );
2115 self.find_closest_definition_excluding(
2116 file_path,
2117 &usage.name,
2118 Some(current_def),
2119 )
2120 } else {
2121 self.find_closest_definition(file_path, &usage.name)
2123 }
2124 } else {
2125 self.find_closest_definition(file_path, &usage.name)
2127 };
2128
2129 if let Some(resolved_def) = resolved_def {
2130 if resolved_def == *definition {
2131 debug!(
2132 "Usage at {:?}:{} resolves to our definition",
2133 file_path, usage.line
2134 );
2135 matching_references.push(usage.clone());
2136 } else {
2137 debug!(
2138 "Usage at {:?}:{} resolves to different definition at {:?}:{}",
2139 file_path, usage.line, resolved_def.file_path, resolved_def.line
2140 );
2141 }
2142 }
2143 }
2144 }
2145 }
2146
2147 info!(
2148 "Found {} references that resolve to this specific definition",
2149 matching_references.len()
2150 );
2151 matching_references
2152 }
2153
2154 pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
2156 self.undeclared_fixtures
2157 .get(file_path)
2158 .map(|entry| entry.value().clone())
2159 .unwrap_or_default()
2160 }
2161
2162 pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
2165 let mut available_fixtures = Vec::new();
2166 let mut seen_names = std::collections::HashSet::new();
2167
2168 for entry in self.definitions.iter() {
2170 let fixture_name = entry.key();
2171 for def in entry.value().iter() {
2172 if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
2173 available_fixtures.push(def.clone());
2174 seen_names.insert(fixture_name.clone());
2175 }
2176 }
2177 }
2178
2179 if let Some(mut current_dir) = file_path.parent() {
2181 loop {
2182 let conftest_path = current_dir.join("conftest.py");
2183
2184 for entry in self.definitions.iter() {
2185 let fixture_name = entry.key();
2186 for def in entry.value().iter() {
2187 if def.file_path == conftest_path
2188 && !seen_names.contains(fixture_name.as_str())
2189 {
2190 available_fixtures.push(def.clone());
2191 seen_names.insert(fixture_name.clone());
2192 }
2193 }
2194 }
2195
2196 match current_dir.parent() {
2198 Some(parent) => current_dir = parent,
2199 None => break,
2200 }
2201 }
2202 }
2203
2204 for entry in self.definitions.iter() {
2206 let fixture_name = entry.key();
2207 for def in entry.value().iter() {
2208 if def.file_path.to_string_lossy().contains("site-packages")
2209 && !seen_names.contains(fixture_name.as_str())
2210 {
2211 available_fixtures.push(def.clone());
2212 seen_names.insert(fixture_name.clone());
2213 }
2214 }
2215 }
2216
2217 available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
2219 available_fixtures
2220 }
2221
2222 pub fn is_inside_function(
2225 &self,
2226 file_path: &Path,
2227 line: u32,
2228 character: u32,
2229 ) -> Option<(String, bool, Vec<String>)> {
2230 let content = self.get_file_content(file_path)?;
2232
2233 let target_line = (line + 1) as usize; let parsed = parse(&content, Mode::Module, "").ok()?;
2237
2238 if let rustpython_parser::ast::Mod::Module(module) = parsed {
2239 return self.find_enclosing_function(
2240 &module.body,
2241 &content,
2242 target_line,
2243 character as usize,
2244 );
2245 }
2246
2247 None
2248 }
2249
2250 fn find_enclosing_function(
2251 &self,
2252 stmts: &[Stmt],
2253 content: &str,
2254 target_line: usize,
2255 _target_char: usize,
2256 ) -> Option<(String, bool, Vec<String>)> {
2257 for stmt in stmts {
2258 match stmt {
2259 Stmt::FunctionDef(func_def) => {
2260 let func_start_line = content[..func_def.range.start().to_usize()]
2261 .matches('\n')
2262 .count()
2263 + 1;
2264 let func_end_line = content[..func_def.range.end().to_usize()]
2265 .matches('\n')
2266 .count()
2267 + 1;
2268
2269 if target_line >= func_start_line && target_line <= func_end_line {
2271 let is_fixture = func_def
2272 .decorator_list
2273 .iter()
2274 .any(Self::is_fixture_decorator);
2275 let is_test = func_def.name.starts_with("test_");
2276
2277 if is_test || is_fixture {
2279 let params: Vec<String> = func_def
2280 .args
2281 .args
2282 .iter()
2283 .map(|arg| arg.def.arg.to_string())
2284 .collect();
2285
2286 return Some((func_def.name.to_string(), is_fixture, params));
2287 }
2288 }
2289 }
2290 Stmt::AsyncFunctionDef(func_def) => {
2291 let func_start_line = content[..func_def.range.start().to_usize()]
2292 .matches('\n')
2293 .count()
2294 + 1;
2295 let func_end_line = content[..func_def.range.end().to_usize()]
2296 .matches('\n')
2297 .count()
2298 + 1;
2299
2300 if target_line >= func_start_line && target_line <= func_end_line {
2301 let is_fixture = func_def
2302 .decorator_list
2303 .iter()
2304 .any(Self::is_fixture_decorator);
2305 let is_test = func_def.name.starts_with("test_");
2306
2307 if is_test || is_fixture {
2308 let params: Vec<String> = func_def
2309 .args
2310 .args
2311 .iter()
2312 .map(|arg| arg.def.arg.to_string())
2313 .collect();
2314
2315 return Some((func_def.name.to_string(), is_fixture, params));
2316 }
2317 }
2318 }
2319 _ => {}
2320 }
2321 }
2322
2323 None
2324 }
2325
2326 pub fn print_fixtures_tree(&self, root_path: &Path, skip_unused: bool, only_unused: bool) {
2329 let mut file_fixtures: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
2331
2332 for entry in self.definitions.iter() {
2333 let fixture_name = entry.key();
2334 let definitions = entry.value();
2335
2336 for def in definitions {
2337 file_fixtures
2338 .entry(def.file_path.clone())
2339 .or_default()
2340 .insert(fixture_name.clone());
2341 }
2342 }
2343
2344 let mut fixture_usage_counts: HashMap<String, usize> = HashMap::new();
2346 for entry in self.usages.iter() {
2347 let usages = entry.value();
2348 for usage in usages {
2349 *fixture_usage_counts.entry(usage.name.clone()).or_insert(0) += 1;
2350 }
2351 }
2352
2353 let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
2355 let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
2356
2357 for file_path in file_fixtures.keys() {
2358 all_paths.insert(file_path.clone());
2359
2360 let mut current = file_path.as_path();
2362 while let Some(parent) = current.parent() {
2363 if parent == root_path || parent.as_os_str().is_empty() {
2364 break;
2365 }
2366 all_paths.insert(parent.to_path_buf());
2367 current = parent;
2368 }
2369 }
2370
2371 for path in &all_paths {
2373 if let Some(parent) = path.parent() {
2374 if parent != root_path && !parent.as_os_str().is_empty() {
2375 tree.entry(parent.to_path_buf())
2376 .or_default()
2377 .push(path.clone());
2378 }
2379 }
2380 }
2381
2382 for children in tree.values_mut() {
2384 children.sort();
2385 }
2386
2387 println!("Fixtures tree for: {}", root_path.display());
2389 println!();
2390
2391 if file_fixtures.is_empty() {
2392 println!("No fixtures found in this directory.");
2393 return;
2394 }
2395
2396 let mut top_level: Vec<PathBuf> = all_paths
2398 .iter()
2399 .filter(|p| {
2400 if let Some(parent) = p.parent() {
2401 parent == root_path
2402 } else {
2403 false
2404 }
2405 })
2406 .cloned()
2407 .collect();
2408 top_level.sort();
2409
2410 for (i, path) in top_level.iter().enumerate() {
2411 let is_last = i == top_level.len() - 1;
2412 self.print_tree_node(
2413 path,
2414 &file_fixtures,
2415 &tree,
2416 "",
2417 is_last,
2418 true, &fixture_usage_counts,
2420 skip_unused,
2421 only_unused,
2422 );
2423 }
2424 }
2425
2426 #[allow(clippy::too_many_arguments)]
2427 #[allow(clippy::only_used_in_recursion)]
2428 fn print_tree_node(
2429 &self,
2430 path: &Path,
2431 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2432 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2433 prefix: &str,
2434 is_last: bool,
2435 is_root_level: bool,
2436 fixture_usage_counts: &HashMap<String, usize>,
2437 skip_unused: bool,
2438 only_unused: bool,
2439 ) {
2440 use colored::Colorize;
2441 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
2442
2443 let connector = if is_root_level {
2445 "" } else if is_last {
2447 "└── "
2448 } else {
2449 "├── "
2450 };
2451
2452 if path.is_file() {
2453 if let Some(fixtures) = file_fixtures.get(path) {
2455 let fixture_vec: Vec<_> = fixtures
2457 .iter()
2458 .filter(|fixture_name| {
2459 let usage_count = fixture_usage_counts
2460 .get(*fixture_name)
2461 .copied()
2462 .unwrap_or(0);
2463 if only_unused {
2464 usage_count == 0
2465 } else if skip_unused {
2466 usage_count > 0
2467 } else {
2468 true
2469 }
2470 })
2471 .collect();
2472
2473 if fixture_vec.is_empty() {
2475 return;
2476 }
2477
2478 let file_display = name.to_string().cyan().bold();
2479 println!(
2480 "{}{}{} ({} fixtures)",
2481 prefix,
2482 connector,
2483 file_display,
2484 fixture_vec.len()
2485 );
2486
2487 let new_prefix = if is_root_level {
2489 "".to_string()
2490 } else {
2491 format!("{}{}", prefix, if is_last { " " } else { "│ " })
2492 };
2493
2494 for (j, fixture_name) in fixture_vec.iter().enumerate() {
2495 let is_last_fixture = j == fixture_vec.len() - 1;
2496 let fixture_connector = if is_last_fixture {
2497 "└── "
2498 } else {
2499 "├── "
2500 };
2501
2502 let usage_count = fixture_usage_counts
2504 .get(*fixture_name)
2505 .copied()
2506 .unwrap_or(0);
2507
2508 let fixture_display = if usage_count == 0 {
2510 fixture_name.to_string().dimmed()
2512 } else {
2513 fixture_name.to_string().green()
2515 };
2516
2517 let usage_info = if usage_count == 0 {
2519 "unused".dimmed().to_string()
2520 } else if usage_count == 1 {
2521 format!("{}", "used 1 time".yellow())
2522 } else {
2523 format!("{}", format!("used {} times", usage_count).yellow())
2524 };
2525
2526 println!(
2527 "{}{}{} ({})",
2528 new_prefix, fixture_connector, fixture_display, usage_info
2529 );
2530 }
2531 } else {
2532 println!("{}{}{}", prefix, connector, name);
2533 }
2534 } else {
2535 if let Some(children) = tree.get(path) {
2537 let has_visible_children = children.iter().any(|child| {
2539 Self::has_visible_fixtures(
2540 child,
2541 file_fixtures,
2542 tree,
2543 fixture_usage_counts,
2544 skip_unused,
2545 only_unused,
2546 )
2547 });
2548
2549 if !has_visible_children {
2550 return;
2551 }
2552
2553 let dir_display = format!("{}/", name).blue().bold();
2554 println!("{}{}{}", prefix, connector, dir_display);
2555
2556 let new_prefix = if is_root_level {
2557 "".to_string()
2558 } else {
2559 format!("{}{}", prefix, if is_last { " " } else { "│ " })
2560 };
2561
2562 for (j, child) in children.iter().enumerate() {
2563 let is_last_child = j == children.len() - 1;
2564 self.print_tree_node(
2565 child,
2566 file_fixtures,
2567 tree,
2568 &new_prefix,
2569 is_last_child,
2570 false, fixture_usage_counts,
2572 skip_unused,
2573 only_unused,
2574 );
2575 }
2576 }
2577 }
2578 }
2579
2580 fn has_visible_fixtures(
2581 path: &Path,
2582 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2583 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2584 fixture_usage_counts: &HashMap<String, usize>,
2585 skip_unused: bool,
2586 only_unused: bool,
2587 ) -> bool {
2588 if path.is_file() {
2589 if let Some(fixtures) = file_fixtures.get(path) {
2591 return fixtures.iter().any(|fixture_name| {
2592 let usage_count = fixture_usage_counts.get(fixture_name).copied().unwrap_or(0);
2593 if only_unused {
2594 usage_count == 0
2595 } else if skip_unused {
2596 usage_count > 0
2597 } else {
2598 true
2599 }
2600 });
2601 }
2602 false
2603 } else {
2604 if let Some(children) = tree.get(path) {
2606 children.iter().any(|child| {
2607 Self::has_visible_fixtures(
2608 child,
2609 file_fixtures,
2610 tree,
2611 fixture_usage_counts,
2612 skip_unused,
2613 only_unused,
2614 )
2615 })
2616 } else {
2617 false
2618 }
2619 }
2620 }
2621}