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 pub return_type: Option<String>, }
17
18#[derive(Debug, Clone)]
19pub struct FixtureUsage {
20 pub name: String,
21 pub file_path: PathBuf,
22 pub line: usize,
23 pub start_char: usize, pub end_char: usize, }
26
27#[derive(Debug, Clone)]
28pub struct UndeclaredFixture {
29 pub name: String,
30 pub file_path: PathBuf,
31 pub line: usize,
32 pub start_char: usize,
33 pub end_char: usize,
34 pub function_name: String, pub function_line: usize, }
37
38#[derive(Debug)]
39pub struct FixtureDatabase {
40 pub definitions: Arc<DashMap<String, Vec<FixtureDefinition>>>,
42 pub usages: Arc<DashMap<PathBuf, Vec<FixtureUsage>>>,
44 pub file_cache: Arc<DashMap<PathBuf, Arc<String>>>,
46 pub undeclared_fixtures: Arc<DashMap<PathBuf, Vec<UndeclaredFixture>>>,
48 pub imports: Arc<DashMap<PathBuf, std::collections::HashSet<String>>>,
50}
51
52impl Default for FixtureDatabase {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl FixtureDatabase {
59 pub fn new() -> Self {
60 Self {
61 definitions: Arc::new(DashMap::new()),
62 usages: Arc::new(DashMap::new()),
63 file_cache: Arc::new(DashMap::new()),
64 undeclared_fixtures: Arc::new(DashMap::new()),
65 imports: Arc::new(DashMap::new()),
66 }
67 }
68
69 fn get_file_content(&self, file_path: &Path) -> Option<Arc<String>> {
72 if let Some(cached) = self.file_cache.get(file_path) {
73 Some(Arc::clone(cached.value()))
74 } else {
75 std::fs::read_to_string(file_path).ok().map(Arc::new)
76 }
77 }
78
79 pub fn scan_workspace(&self, root_path: &Path) {
81 info!("Scanning workspace: {:?}", root_path);
82 let mut file_count = 0;
83
84 for entry in WalkDir::new(root_path).into_iter().filter_map(|e| e.ok()) {
85 let path = entry.path();
86
87 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
89 if filename == "conftest.py"
90 || filename.starts_with("test_") && filename.ends_with(".py")
91 || filename.ends_with("_test.py")
92 {
93 debug!("Found test/conftest file: {:?}", path);
94 if let Ok(content) = std::fs::read_to_string(path) {
95 self.analyze_file(path.to_path_buf(), &content);
96 file_count += 1;
97 }
98 }
99 }
100 }
101
102 info!("Workspace scan complete. Processed {} files", file_count);
103
104 self.scan_venv_fixtures(root_path);
106
107 info!("Total fixtures defined: {}", self.definitions.len());
108 info!("Total files with fixture usages: {}", self.usages.len());
109 }
110
111 fn scan_venv_fixtures(&self, root_path: &Path) {
113 info!("Scanning for pytest plugins in virtual environment");
114
115 let venv_paths = vec![
117 root_path.join(".venv"),
118 root_path.join("venv"),
119 root_path.join("env"),
120 ];
121
122 info!("Checking for venv in: {:?}", root_path);
123 for venv_path in &venv_paths {
124 debug!("Checking venv path: {:?}", venv_path);
125 if venv_path.exists() {
126 info!("Found virtual environment at: {:?}", venv_path);
127 self.scan_venv_site_packages(venv_path);
128 return;
129 } else {
130 debug!(" Does not exist: {:?}", venv_path);
131 }
132 }
133
134 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
136 info!("Found VIRTUAL_ENV environment variable: {}", venv);
137 let venv_path = PathBuf::from(venv);
138 if venv_path.exists() {
139 info!("Using VIRTUAL_ENV: {:?}", venv_path);
140 self.scan_venv_site_packages(&venv_path);
141 return;
142 } else {
143 warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
144 }
145 } else {
146 debug!("No VIRTUAL_ENV environment variable set");
147 }
148
149 warn!("No virtual environment found - third-party fixtures will not be available");
150 }
151
152 fn scan_venv_site_packages(&self, venv_path: &Path) {
153 info!("Scanning venv site-packages in: {:?}", venv_path);
154
155 let lib_path = venv_path.join("lib");
157 debug!("Checking lib path: {:?}", lib_path);
158
159 if lib_path.exists() {
160 if let Ok(entries) = std::fs::read_dir(&lib_path) {
162 for entry in entries.flatten() {
163 let path = entry.path();
164 let dirname = path.file_name().unwrap_or_default().to_string_lossy();
165 debug!("Found in lib: {:?}", dirname);
166
167 if path.is_dir() && dirname.starts_with("python") {
168 let site_packages = path.join("site-packages");
169 debug!("Checking site-packages: {:?}", site_packages);
170
171 if site_packages.exists() {
172 info!("Found site-packages: {:?}", site_packages);
173 self.scan_pytest_plugins(&site_packages);
174 return;
175 }
176 }
177 }
178 }
179 }
180
181 let windows_site_packages = venv_path.join("Lib/site-packages");
183 debug!("Checking Windows path: {:?}", windows_site_packages);
184 if windows_site_packages.exists() {
185 info!("Found site-packages (Windows): {:?}", windows_site_packages);
186 self.scan_pytest_plugins(&windows_site_packages);
187 return;
188 }
189
190 warn!("Could not find site-packages in venv: {:?}", venv_path);
191 }
192
193 fn scan_pytest_plugins(&self, site_packages: &Path) {
194 info!("Scanning pytest plugins in: {:?}", site_packages);
195
196 let pytest_packages = vec![
198 "pytest_mock",
199 "pytest-mock",
200 "pytest_asyncio",
201 "pytest-asyncio",
202 "pytest_django",
203 "pytest-django",
204 "pytest_cov",
205 "pytest-cov",
206 "pytest_xdist",
207 "pytest-xdist",
208 "pytest_fixtures",
209 ];
210
211 let mut plugin_count = 0;
212
213 for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
214 let entry = match entry {
215 Ok(e) => e,
216 Err(_) => continue,
217 };
218
219 let path = entry.path();
220 let filename = path.file_name().unwrap_or_default().to_string_lossy();
221
222 let is_pytest_package = pytest_packages.iter().any(|pkg| filename.contains(pkg))
224 || filename.starts_with("pytest")
225 || filename.contains("_pytest");
226
227 if is_pytest_package && path.is_dir() {
228 if filename.ends_with(".dist-info") || filename.ends_with(".egg-info") {
230 debug!("Skipping dist-info directory: {:?}", filename);
231 continue;
232 }
233
234 info!("Scanning pytest plugin: {:?}", path);
235 plugin_count += 1;
236 self.scan_plugin_directory(&path);
237 } else {
238 if filename.contains("mock") {
240 debug!("Found mock-related package (not scanning): {:?}", filename);
241 }
242 }
243 }
244
245 info!("Scanned {} pytest plugin packages", plugin_count);
246 }
247
248 fn scan_plugin_directory(&self, plugin_dir: &Path) {
249 for entry in WalkDir::new(plugin_dir)
251 .max_depth(3) .into_iter()
253 .filter_map(|e| e.ok())
254 {
255 let path = entry.path();
256
257 if path.extension().and_then(|s| s.to_str()) == Some("py") {
258 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
260 if filename.starts_with("test_") || filename.contains("__pycache__") {
262 continue;
263 }
264
265 debug!("Scanning plugin file: {:?}", path);
266 if let Ok(content) = std::fs::read_to_string(path) {
267 self.analyze_file(path.to_path_buf(), &content);
268 }
269 }
270 }
271 }
272 }
273
274 pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
276 let file_path = file_path.canonicalize().unwrap_or_else(|_| {
279 debug!(
282 "Warning: Could not canonicalize path {:?}, using as-is",
283 file_path
284 );
285 file_path
286 });
287
288 debug!("Analyzing file: {:?}", file_path);
289
290 self.file_cache
293 .insert(file_path.clone(), Arc::new(content.to_string()));
294
295 let parsed = match parse(content, Mode::Module, "") {
297 Ok(ast) => ast,
298 Err(e) => {
299 warn!("Failed to parse {:?}: {:?}", file_path, e);
300 return;
301 }
302 };
303
304 self.usages.remove(&file_path);
306
307 self.undeclared_fixtures.remove(&file_path);
309
310 self.imports.remove(&file_path);
312
313 for mut entry in self.definitions.iter_mut() {
316 entry.value_mut().retain(|def| def.file_path != file_path);
317 }
318 self.definitions.retain(|_, defs| !defs.is_empty());
320
321 let is_conftest = file_path
323 .file_name()
324 .map(|n| n == "conftest.py")
325 .unwrap_or(false);
326 debug!("is_conftest: {}", is_conftest);
327
328 if let rustpython_parser::ast::Mod::Module(module) = parsed {
330 debug!("Module has {} statements", module.body.len());
331
332 let mut module_level_names = std::collections::HashSet::new();
334 for stmt in &module.body {
335 self.collect_module_level_names(stmt, &mut module_level_names);
336 }
337 self.imports.insert(file_path.clone(), module_level_names);
338
339 for stmt in &module.body {
341 self.visit_stmt(stmt, &file_path, is_conftest, content);
342 }
343 }
344
345 debug!("Analysis complete for {:?}", file_path);
346 }
347
348 fn visit_stmt(&self, stmt: &Stmt, file_path: &PathBuf, _is_conftest: bool, content: &str) {
349 if let Stmt::Assign(assign) = stmt {
351 self.visit_assignment_fixture(assign, file_path, content);
352 }
353
354 let (func_name, decorator_list, args, range, body, returns) = match stmt {
356 Stmt::FunctionDef(func_def) => (
357 func_def.name.as_str(),
358 &func_def.decorator_list,
359 &func_def.args,
360 func_def.range,
361 &func_def.body,
362 &func_def.returns,
363 ),
364 Stmt::AsyncFunctionDef(func_def) => (
365 func_def.name.as_str(),
366 &func_def.decorator_list,
367 &func_def.args,
368 func_def.range,
369 &func_def.body,
370 &func_def.returns,
371 ),
372 _ => return,
373 };
374
375 debug!("Found function: {}", func_name);
376
377 debug!(
379 "Function {} has {} decorators",
380 func_name,
381 decorator_list.len()
382 );
383 let is_fixture = decorator_list.iter().any(|dec| {
384 let result = Self::is_fixture_decorator(dec);
385 if result {
386 debug!(" Decorator matched as fixture!");
387 }
388 result
389 });
390
391 if is_fixture {
392 let line = self.get_line_from_offset(range.start().to_usize(), content);
394
395 let docstring = self.extract_docstring(body);
397
398 let return_type = self.extract_return_type(returns, body, content);
400
401 info!(
402 "Found fixture definition: {} at {:?}:{}",
403 func_name, file_path, line
404 );
405 if let Some(ref doc) = docstring {
406 debug!(" Docstring: {}", doc);
407 }
408 if let Some(ref ret_type) = return_type {
409 debug!(" Return type: {}", ret_type);
410 }
411
412 let definition = FixtureDefinition {
413 name: func_name.to_string(),
414 file_path: file_path.clone(),
415 line,
416 docstring,
417 return_type,
418 };
419
420 self.definitions
421 .entry(func_name.to_string())
422 .or_default()
423 .push(definition);
424
425 let mut declared_params: std::collections::HashSet<String> =
427 std::collections::HashSet::new();
428 declared_params.insert("self".to_string());
429 declared_params.insert("request".to_string());
430 declared_params.insert(func_name.to_string()); for arg in &args.args {
433 let arg_name = arg.def.arg.as_str();
434 declared_params.insert(arg_name.to_string());
435
436 if arg_name != "self" && arg_name != "request" {
437 let arg_line =
440 self.get_line_from_offset(arg.def.range.start().to_usize(), content);
441 let start_char = self
442 .get_char_position_from_offset(arg.def.range.start().to_usize(), content);
443 let end_char =
444 self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
445
446 info!(
447 "Found fixture dependency: {} at {:?}:{}:{}",
448 arg_name, file_path, arg_line, start_char
449 );
450
451 let usage = FixtureUsage {
452 name: arg_name.to_string(),
453 file_path: file_path.clone(),
454 line: arg_line, start_char,
456 end_char,
457 };
458
459 self.usages
460 .entry(file_path.clone())
461 .or_default()
462 .push(usage);
463 }
464 }
465
466 let function_line = self.get_line_from_offset(range.start().to_usize(), content);
468 self.scan_function_body_for_undeclared_fixtures(
469 body,
470 file_path,
471 content,
472 &declared_params,
473 func_name,
474 function_line,
475 );
476 }
477
478 let is_test = func_name.starts_with("test_");
480
481 if is_test {
482 debug!("Found test function: {}", func_name);
483
484 let mut declared_params: std::collections::HashSet<String> =
486 std::collections::HashSet::new();
487 declared_params.insert("self".to_string());
488 declared_params.insert("request".to_string()); for arg in &args.args {
492 let arg_name = arg.def.arg.as_str();
493 declared_params.insert(arg_name.to_string());
494
495 if arg_name != "self" {
496 let arg_offset = arg.def.range.start().to_usize();
500 let arg_line = self.get_line_from_offset(arg_offset, content);
501 let start_char = self.get_char_position_from_offset(arg_offset, content);
502 let end_char =
503 self.get_char_position_from_offset(arg.def.range.end().to_usize(), content);
504
505 debug!(
506 "Parameter {} at offset {}, calculated line {}, char {}",
507 arg_name, arg_offset, arg_line, start_char
508 );
509 info!(
510 "Found fixture usage: {} at {:?}:{}:{}",
511 arg_name, file_path, arg_line, start_char
512 );
513
514 let usage = FixtureUsage {
515 name: arg_name.to_string(),
516 file_path: file_path.clone(),
517 line: arg_line, start_char,
519 end_char,
520 };
521
522 self.usages
524 .entry(file_path.clone())
525 .or_default()
526 .push(usage);
527 }
528 }
529
530 let function_line = self.get_line_from_offset(range.start().to_usize(), content);
532 self.scan_function_body_for_undeclared_fixtures(
533 body,
534 file_path,
535 content,
536 &declared_params,
537 func_name,
538 function_line,
539 );
540 }
541 }
542
543 fn visit_assignment_fixture(
544 &self,
545 assign: &rustpython_parser::ast::StmtAssign,
546 file_path: &PathBuf,
547 content: &str,
548 ) {
549 if let Expr::Call(outer_call) = &*assign.value {
553 if let Expr::Call(inner_call) = &*outer_call.func {
555 if Self::is_fixture_decorator(&inner_call.func) {
556 for target in &assign.targets {
559 if let Expr::Name(name) = target {
560 let fixture_name = name.id.as_str();
561 let line =
562 self.get_line_from_offset(assign.range.start().to_usize(), content);
563
564 info!(
565 "Found fixture assignment: {} at {:?}:{}",
566 fixture_name, file_path, line
567 );
568
569 let definition = FixtureDefinition {
571 name: fixture_name.to_string(),
572 file_path: file_path.clone(),
573 line,
574 docstring: None,
575 return_type: None,
576 };
577
578 self.definitions
579 .entry(fixture_name.to_string())
580 .or_default()
581 .push(definition);
582 }
583 }
584 }
585 }
586 }
587 }
588
589 fn is_fixture_decorator(expr: &Expr) -> bool {
590 match expr {
591 Expr::Name(name) => name.id.as_str() == "fixture",
592 Expr::Attribute(attr) => {
593 if let Expr::Name(value) = &*attr.value {
595 value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
596 } else {
597 false
598 }
599 }
600 Expr::Call(call) => {
601 Self::is_fixture_decorator(&call.func)
603 }
604 _ => false,
605 }
606 }
607
608 fn scan_function_body_for_undeclared_fixtures(
609 &self,
610 body: &[Stmt],
611 file_path: &PathBuf,
612 content: &str,
613 declared_params: &std::collections::HashSet<String>,
614 function_name: &str,
615 function_line: usize,
616 ) {
617 let mut local_vars = std::collections::HashMap::new();
619 self.collect_local_variables(body, content, &mut local_vars);
620
621 if let Some(imports) = self.imports.get(file_path) {
624 for import in imports.iter() {
625 local_vars.insert(import.clone(), 0);
626 }
627 }
628
629 for stmt in body {
631 self.visit_stmt_for_names(
632 stmt,
633 file_path,
634 content,
635 declared_params,
636 &local_vars,
637 function_name,
638 function_line,
639 );
640 }
641 }
642
643 fn collect_module_level_names(
644 &self,
645 stmt: &Stmt,
646 names: &mut std::collections::HashSet<String>,
647 ) {
648 match stmt {
649 Stmt::Import(import_stmt) => {
651 for alias in &import_stmt.names {
652 let name = alias.asname.as_ref().unwrap_or(&alias.name);
654 names.insert(name.to_string());
655 }
656 }
657 Stmt::ImportFrom(import_from) => {
658 for alias in &import_from.names {
659 let name = alias.asname.as_ref().unwrap_or(&alias.name);
661 names.insert(name.to_string());
662 }
663 }
664 Stmt::FunctionDef(func_def) => {
666 let is_fixture = func_def
668 .decorator_list
669 .iter()
670 .any(Self::is_fixture_decorator);
671 if !is_fixture {
672 names.insert(func_def.name.to_string());
673 }
674 }
675 Stmt::AsyncFunctionDef(func_def) => {
677 let is_fixture = func_def
678 .decorator_list
679 .iter()
680 .any(Self::is_fixture_decorator);
681 if !is_fixture {
682 names.insert(func_def.name.to_string());
683 }
684 }
685 Stmt::ClassDef(class_def) => {
687 names.insert(class_def.name.to_string());
688 }
689 Stmt::Assign(assign) => {
691 for target in &assign.targets {
692 self.collect_names_from_expr(target, names);
693 }
694 }
695 Stmt::AnnAssign(ann_assign) => {
696 self.collect_names_from_expr(&ann_assign.target, names);
697 }
698 _ => {}
699 }
700 }
701
702 fn collect_local_variables(
703 &self,
704 body: &[Stmt],
705 content: &str,
706 local_vars: &mut std::collections::HashMap<String, usize>,
707 ) {
708 for stmt in body {
709 match stmt {
710 Stmt::Assign(assign) => {
711 let line = self.get_line_from_offset(assign.range.start().to_usize(), content);
713 let mut temp_names = std::collections::HashSet::new();
714 for target in &assign.targets {
715 self.collect_names_from_expr(target, &mut temp_names);
716 }
717 for name in temp_names {
718 local_vars.insert(name, line);
719 }
720 }
721 Stmt::AnnAssign(ann_assign) => {
722 let line =
724 self.get_line_from_offset(ann_assign.range.start().to_usize(), content);
725 let mut temp_names = std::collections::HashSet::new();
726 self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
727 for name in temp_names {
728 local_vars.insert(name, line);
729 }
730 }
731 Stmt::AugAssign(aug_assign) => {
732 let line =
734 self.get_line_from_offset(aug_assign.range.start().to_usize(), content);
735 let mut temp_names = std::collections::HashSet::new();
736 self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
737 for name in temp_names {
738 local_vars.insert(name, line);
739 }
740 }
741 Stmt::For(for_stmt) => {
742 let line =
744 self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
745 let mut temp_names = std::collections::HashSet::new();
746 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
747 for name in temp_names {
748 local_vars.insert(name, line);
749 }
750 self.collect_local_variables(&for_stmt.body, content, local_vars);
752 }
753 Stmt::AsyncFor(for_stmt) => {
754 let line =
755 self.get_line_from_offset(for_stmt.range.start().to_usize(), content);
756 let mut temp_names = std::collections::HashSet::new();
757 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
758 for name in temp_names {
759 local_vars.insert(name, line);
760 }
761 self.collect_local_variables(&for_stmt.body, content, local_vars);
762 }
763 Stmt::While(while_stmt) => {
764 self.collect_local_variables(&while_stmt.body, content, local_vars);
765 }
766 Stmt::If(if_stmt) => {
767 self.collect_local_variables(&if_stmt.body, content, local_vars);
768 self.collect_local_variables(&if_stmt.orelse, content, local_vars);
769 }
770 Stmt::With(with_stmt) => {
771 let line =
773 self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
774 for item in &with_stmt.items {
775 if let Some(ref optional_vars) = item.optional_vars {
776 let mut temp_names = std::collections::HashSet::new();
777 self.collect_names_from_expr(optional_vars, &mut temp_names);
778 for name in temp_names {
779 local_vars.insert(name, line);
780 }
781 }
782 }
783 self.collect_local_variables(&with_stmt.body, content, local_vars);
784 }
785 Stmt::AsyncWith(with_stmt) => {
786 let line =
787 self.get_line_from_offset(with_stmt.range.start().to_usize(), content);
788 for item in &with_stmt.items {
789 if let Some(ref optional_vars) = item.optional_vars {
790 let mut temp_names = std::collections::HashSet::new();
791 self.collect_names_from_expr(optional_vars, &mut temp_names);
792 for name in temp_names {
793 local_vars.insert(name, line);
794 }
795 }
796 }
797 self.collect_local_variables(&with_stmt.body, content, local_vars);
798 }
799 Stmt::Try(try_stmt) => {
800 self.collect_local_variables(&try_stmt.body, content, local_vars);
801 self.collect_local_variables(&try_stmt.orelse, content, local_vars);
804 self.collect_local_variables(&try_stmt.finalbody, content, local_vars);
805 }
806 _ => {}
807 }
808 }
809 }
810
811 #[allow(clippy::only_used_in_recursion)]
812 fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
813 match expr {
814 Expr::Name(name) => {
815 names.insert(name.id.to_string());
816 }
817 Expr::Tuple(tuple) => {
818 for elt in &tuple.elts {
819 self.collect_names_from_expr(elt, names);
820 }
821 }
822 Expr::List(list) => {
823 for elt in &list.elts {
824 self.collect_names_from_expr(elt, names);
825 }
826 }
827 _ => {}
828 }
829 }
830
831 #[allow(clippy::too_many_arguments)]
832 fn visit_stmt_for_names(
833 &self,
834 stmt: &Stmt,
835 file_path: &PathBuf,
836 content: &str,
837 declared_params: &std::collections::HashSet<String>,
838 local_vars: &std::collections::HashMap<String, usize>,
839 function_name: &str,
840 function_line: usize,
841 ) {
842 match stmt {
843 Stmt::Expr(expr_stmt) => {
844 self.visit_expr_for_names(
845 &expr_stmt.value,
846 file_path,
847 content,
848 declared_params,
849 local_vars,
850 function_name,
851 function_line,
852 );
853 }
854 Stmt::Assign(assign) => {
855 self.visit_expr_for_names(
856 &assign.value,
857 file_path,
858 content,
859 declared_params,
860 local_vars,
861 function_name,
862 function_line,
863 );
864 }
865 Stmt::AugAssign(aug_assign) => {
866 self.visit_expr_for_names(
867 &aug_assign.value,
868 file_path,
869 content,
870 declared_params,
871 local_vars,
872 function_name,
873 function_line,
874 );
875 }
876 Stmt::Return(ret) => {
877 if let Some(ref value) = ret.value {
878 self.visit_expr_for_names(
879 value,
880 file_path,
881 content,
882 declared_params,
883 local_vars,
884 function_name,
885 function_line,
886 );
887 }
888 }
889 Stmt::If(if_stmt) => {
890 self.visit_expr_for_names(
891 &if_stmt.test,
892 file_path,
893 content,
894 declared_params,
895 local_vars,
896 function_name,
897 function_line,
898 );
899 for stmt in &if_stmt.body {
900 self.visit_stmt_for_names(
901 stmt,
902 file_path,
903 content,
904 declared_params,
905 local_vars,
906 function_name,
907 function_line,
908 );
909 }
910 for stmt in &if_stmt.orelse {
911 self.visit_stmt_for_names(
912 stmt,
913 file_path,
914 content,
915 declared_params,
916 local_vars,
917 function_name,
918 function_line,
919 );
920 }
921 }
922 Stmt::While(while_stmt) => {
923 self.visit_expr_for_names(
924 &while_stmt.test,
925 file_path,
926 content,
927 declared_params,
928 local_vars,
929 function_name,
930 function_line,
931 );
932 for stmt in &while_stmt.body {
933 self.visit_stmt_for_names(
934 stmt,
935 file_path,
936 content,
937 declared_params,
938 local_vars,
939 function_name,
940 function_line,
941 );
942 }
943 }
944 Stmt::For(for_stmt) => {
945 self.visit_expr_for_names(
946 &for_stmt.iter,
947 file_path,
948 content,
949 declared_params,
950 local_vars,
951 function_name,
952 function_line,
953 );
954 for stmt in &for_stmt.body {
955 self.visit_stmt_for_names(
956 stmt,
957 file_path,
958 content,
959 declared_params,
960 local_vars,
961 function_name,
962 function_line,
963 );
964 }
965 }
966 Stmt::With(with_stmt) => {
967 for item in &with_stmt.items {
968 self.visit_expr_for_names(
969 &item.context_expr,
970 file_path,
971 content,
972 declared_params,
973 local_vars,
974 function_name,
975 function_line,
976 );
977 }
978 for stmt in &with_stmt.body {
979 self.visit_stmt_for_names(
980 stmt,
981 file_path,
982 content,
983 declared_params,
984 local_vars,
985 function_name,
986 function_line,
987 );
988 }
989 }
990 Stmt::AsyncFor(for_stmt) => {
991 self.visit_expr_for_names(
992 &for_stmt.iter,
993 file_path,
994 content,
995 declared_params,
996 local_vars,
997 function_name,
998 function_line,
999 );
1000 for stmt in &for_stmt.body {
1001 self.visit_stmt_for_names(
1002 stmt,
1003 file_path,
1004 content,
1005 declared_params,
1006 local_vars,
1007 function_name,
1008 function_line,
1009 );
1010 }
1011 }
1012 Stmt::AsyncWith(with_stmt) => {
1013 for item in &with_stmt.items {
1014 self.visit_expr_for_names(
1015 &item.context_expr,
1016 file_path,
1017 content,
1018 declared_params,
1019 local_vars,
1020 function_name,
1021 function_line,
1022 );
1023 }
1024 for stmt in &with_stmt.body {
1025 self.visit_stmt_for_names(
1026 stmt,
1027 file_path,
1028 content,
1029 declared_params,
1030 local_vars,
1031 function_name,
1032 function_line,
1033 );
1034 }
1035 }
1036 Stmt::Assert(assert_stmt) => {
1037 self.visit_expr_for_names(
1038 &assert_stmt.test,
1039 file_path,
1040 content,
1041 declared_params,
1042 local_vars,
1043 function_name,
1044 function_line,
1045 );
1046 if let Some(ref msg) = assert_stmt.msg {
1047 self.visit_expr_for_names(
1048 msg,
1049 file_path,
1050 content,
1051 declared_params,
1052 local_vars,
1053 function_name,
1054 function_line,
1055 );
1056 }
1057 }
1058 _ => {} }
1060 }
1061
1062 #[allow(clippy::too_many_arguments)]
1063 fn visit_expr_for_names(
1064 &self,
1065 expr: &Expr,
1066 file_path: &PathBuf,
1067 content: &str,
1068 declared_params: &std::collections::HashSet<String>,
1069 local_vars: &std::collections::HashMap<String, usize>,
1070 function_name: &str,
1071 function_line: usize,
1072 ) {
1073 match expr {
1074 Expr::Name(name) => {
1075 let name_str = name.id.as_str();
1076 let line = self.get_line_from_offset(name.range.start().to_usize(), content);
1077
1078 let is_local_var_in_scope = local_vars
1082 .get(name_str)
1083 .map(|def_line| *def_line < line)
1084 .unwrap_or(false);
1085
1086 if !declared_params.contains(name_str)
1087 && !is_local_var_in_scope
1088 && self.is_available_fixture(file_path, name_str)
1089 {
1090 let start_char =
1091 self.get_char_position_from_offset(name.range.start().to_usize(), content);
1092 let end_char =
1093 self.get_char_position_from_offset(name.range.end().to_usize(), content);
1094
1095 info!(
1096 "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1097 name_str, file_path, line, start_char, function_name
1098 );
1099
1100 let undeclared = UndeclaredFixture {
1101 name: name_str.to_string(),
1102 file_path: file_path.clone(),
1103 line,
1104 start_char,
1105 end_char,
1106 function_name: function_name.to_string(),
1107 function_line,
1108 };
1109
1110 self.undeclared_fixtures
1111 .entry(file_path.clone())
1112 .or_default()
1113 .push(undeclared);
1114 }
1115 }
1116 Expr::Call(call) => {
1117 self.visit_expr_for_names(
1118 &call.func,
1119 file_path,
1120 content,
1121 declared_params,
1122 local_vars,
1123 function_name,
1124 function_line,
1125 );
1126 for arg in &call.args {
1127 self.visit_expr_for_names(
1128 arg,
1129 file_path,
1130 content,
1131 declared_params,
1132 local_vars,
1133 function_name,
1134 function_line,
1135 );
1136 }
1137 }
1138 Expr::Attribute(attr) => {
1139 self.visit_expr_for_names(
1140 &attr.value,
1141 file_path,
1142 content,
1143 declared_params,
1144 local_vars,
1145 function_name,
1146 function_line,
1147 );
1148 }
1149 Expr::BinOp(binop) => {
1150 self.visit_expr_for_names(
1151 &binop.left,
1152 file_path,
1153 content,
1154 declared_params,
1155 local_vars,
1156 function_name,
1157 function_line,
1158 );
1159 self.visit_expr_for_names(
1160 &binop.right,
1161 file_path,
1162 content,
1163 declared_params,
1164 local_vars,
1165 function_name,
1166 function_line,
1167 );
1168 }
1169 Expr::UnaryOp(unaryop) => {
1170 self.visit_expr_for_names(
1171 &unaryop.operand,
1172 file_path,
1173 content,
1174 declared_params,
1175 local_vars,
1176 function_name,
1177 function_line,
1178 );
1179 }
1180 Expr::Compare(compare) => {
1181 self.visit_expr_for_names(
1182 &compare.left,
1183 file_path,
1184 content,
1185 declared_params,
1186 local_vars,
1187 function_name,
1188 function_line,
1189 );
1190 for comparator in &compare.comparators {
1191 self.visit_expr_for_names(
1192 comparator,
1193 file_path,
1194 content,
1195 declared_params,
1196 local_vars,
1197 function_name,
1198 function_line,
1199 );
1200 }
1201 }
1202 Expr::Subscript(subscript) => {
1203 self.visit_expr_for_names(
1204 &subscript.value,
1205 file_path,
1206 content,
1207 declared_params,
1208 local_vars,
1209 function_name,
1210 function_line,
1211 );
1212 self.visit_expr_for_names(
1213 &subscript.slice,
1214 file_path,
1215 content,
1216 declared_params,
1217 local_vars,
1218 function_name,
1219 function_line,
1220 );
1221 }
1222 Expr::List(list) => {
1223 for elt in &list.elts {
1224 self.visit_expr_for_names(
1225 elt,
1226 file_path,
1227 content,
1228 declared_params,
1229 local_vars,
1230 function_name,
1231 function_line,
1232 );
1233 }
1234 }
1235 Expr::Tuple(tuple) => {
1236 for elt in &tuple.elts {
1237 self.visit_expr_for_names(
1238 elt,
1239 file_path,
1240 content,
1241 declared_params,
1242 local_vars,
1243 function_name,
1244 function_line,
1245 );
1246 }
1247 }
1248 Expr::Dict(dict) => {
1249 for k in dict.keys.iter().flatten() {
1250 self.visit_expr_for_names(
1251 k,
1252 file_path,
1253 content,
1254 declared_params,
1255 local_vars,
1256 function_name,
1257 function_line,
1258 );
1259 }
1260 for value in &dict.values {
1261 self.visit_expr_for_names(
1262 value,
1263 file_path,
1264 content,
1265 declared_params,
1266 local_vars,
1267 function_name,
1268 function_line,
1269 );
1270 }
1271 }
1272 Expr::Await(await_expr) => {
1273 self.visit_expr_for_names(
1275 &await_expr.value,
1276 file_path,
1277 content,
1278 declared_params,
1279 local_vars,
1280 function_name,
1281 function_line,
1282 );
1283 }
1284 _ => {} }
1286 }
1287
1288 fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1289 if let Some(definitions) = self.definitions.get(fixture_name) {
1291 for def in definitions.iter() {
1293 if def.file_path == file_path {
1295 return true;
1296 }
1297
1298 if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1300 && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1301 {
1302 return true;
1303 }
1304
1305 if def.file_path.to_string_lossy().contains("site-packages") {
1307 return true;
1308 }
1309 }
1310 }
1311 false
1312 }
1313
1314 fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1315 if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1317 if let Expr::Constant(constant) = &*expr_stmt.value {
1318 if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1320 return Some(self.format_docstring(s.to_string()));
1321 }
1322 }
1323 }
1324 None
1325 }
1326
1327 fn format_docstring(&self, docstring: String) -> String {
1328 let lines: Vec<&str> = docstring.lines().collect();
1331
1332 if lines.is_empty() {
1333 return String::new();
1334 }
1335
1336 let mut start = 0;
1338 let mut end = lines.len();
1339
1340 while start < lines.len() && lines[start].trim().is_empty() {
1341 start += 1;
1342 }
1343
1344 while end > start && lines[end - 1].trim().is_empty() {
1345 end -= 1;
1346 }
1347
1348 if start >= end {
1349 return String::new();
1350 }
1351
1352 let lines = &lines[start..end];
1353
1354 let mut min_indent = usize::MAX;
1356 for (i, line) in lines.iter().enumerate() {
1357 if i == 0 && !line.trim().is_empty() {
1358 continue;
1360 }
1361
1362 if !line.trim().is_empty() {
1363 let indent = line.len() - line.trim_start().len();
1364 min_indent = min_indent.min(indent);
1365 }
1366 }
1367
1368 if min_indent == usize::MAX {
1369 min_indent = 0;
1370 }
1371
1372 let mut result = Vec::new();
1374 for (i, line) in lines.iter().enumerate() {
1375 if i == 0 {
1376 result.push(line.trim().to_string());
1378 } else if line.trim().is_empty() {
1379 result.push(String::new());
1381 } else {
1382 let dedented = if line.len() > min_indent {
1384 &line[min_indent..]
1385 } else {
1386 line.trim_start()
1387 };
1388 result.push(dedented.to_string());
1389 }
1390 }
1391
1392 result.join("\n")
1394 }
1395
1396 fn extract_return_type(
1397 &self,
1398 returns: &Option<Box<rustpython_parser::ast::Expr>>,
1399 body: &[Stmt],
1400 content: &str,
1401 ) -> Option<String> {
1402 if let Some(return_expr) = returns {
1403 let has_yield = self.contains_yield(body);
1405
1406 if has_yield {
1407 return self.extract_yielded_type(return_expr, content);
1410 } else {
1411 return Some(self.expr_to_string(return_expr, content));
1413 }
1414 }
1415 None
1416 }
1417
1418 #[allow(clippy::only_used_in_recursion)]
1419 fn contains_yield(&self, body: &[Stmt]) -> bool {
1420 for stmt in body {
1421 match stmt {
1422 Stmt::Expr(expr_stmt) => {
1423 if let Expr::Yield(_) | Expr::YieldFrom(_) = &*expr_stmt.value {
1424 return true;
1425 }
1426 }
1427 Stmt::If(if_stmt) => {
1428 if self.contains_yield(&if_stmt.body) || self.contains_yield(&if_stmt.orelse) {
1429 return true;
1430 }
1431 }
1432 Stmt::For(for_stmt) => {
1433 if self.contains_yield(&for_stmt.body) || self.contains_yield(&for_stmt.orelse)
1434 {
1435 return true;
1436 }
1437 }
1438 Stmt::While(while_stmt) => {
1439 if self.contains_yield(&while_stmt.body)
1440 || self.contains_yield(&while_stmt.orelse)
1441 {
1442 return true;
1443 }
1444 }
1445 Stmt::With(with_stmt) => {
1446 if self.contains_yield(&with_stmt.body) {
1447 return true;
1448 }
1449 }
1450 Stmt::Try(try_stmt) => {
1451 if self.contains_yield(&try_stmt.body)
1452 || self.contains_yield(&try_stmt.orelse)
1453 || self.contains_yield(&try_stmt.finalbody)
1454 {
1455 return true;
1456 }
1457 }
1460 _ => {}
1461 }
1462 }
1463 false
1464 }
1465
1466 fn extract_yielded_type(
1467 &self,
1468 expr: &rustpython_parser::ast::Expr,
1469 content: &str,
1470 ) -> Option<String> {
1471 if let Expr::Subscript(subscript) = expr {
1475 let _base_name = self.expr_to_string(&subscript.value, content);
1477
1478 if let Expr::Tuple(tuple) = &*subscript.slice {
1480 if let Some(first_elem) = tuple.elts.first() {
1481 return Some(self.expr_to_string(first_elem, content));
1482 }
1483 } else {
1484 return Some(self.expr_to_string(&subscript.slice, content));
1486 }
1487 }
1488
1489 Some(self.expr_to_string(expr, content))
1491 }
1492
1493 #[allow(clippy::only_used_in_recursion)]
1494 fn expr_to_string(&self, expr: &rustpython_parser::ast::Expr, _content: &str) -> String {
1495 match expr {
1496 Expr::Name(name) => name.id.to_string(),
1497 Expr::Attribute(attr) => {
1498 format!(
1499 "{}.{}",
1500 self.expr_to_string(&attr.value, _content),
1501 attr.attr
1502 )
1503 }
1504 Expr::Subscript(subscript) => {
1505 let base = self.expr_to_string(&subscript.value, _content);
1506 let slice = self.expr_to_string(&subscript.slice, _content);
1507 format!("{}[{}]", base, slice)
1508 }
1509 Expr::Tuple(tuple) => {
1510 let elements: Vec<String> = tuple
1511 .elts
1512 .iter()
1513 .map(|e| self.expr_to_string(e, _content))
1514 .collect();
1515 elements.join(", ")
1516 }
1517 Expr::Constant(constant) => {
1518 format!("{:?}", constant.value)
1519 }
1520 Expr::BinOp(binop) if matches!(binop.op, rustpython_parser::ast::Operator::BitOr) => {
1521 format!(
1523 "{} | {}",
1524 self.expr_to_string(&binop.left, _content),
1525 self.expr_to_string(&binop.right, _content)
1526 )
1527 }
1528 _ => {
1529 "Any".to_string()
1531 }
1532 }
1533 }
1534
1535 fn get_line_from_offset(&self, offset: usize, content: &str) -> usize {
1536 content[..offset].matches('\n').count() + 1
1538 }
1539
1540 fn get_char_position_from_offset(&self, offset: usize, content: &str) -> usize {
1541 if let Some(line_start) = content[..offset].rfind('\n') {
1543 offset - line_start - 1
1545 } else {
1546 offset
1548 }
1549 }
1550
1551 pub fn find_fixture_definition(
1553 &self,
1554 file_path: &Path,
1555 line: u32,
1556 character: u32,
1557 ) -> Option<FixtureDefinition> {
1558 debug!(
1559 "find_fixture_definition: file={:?}, line={}, char={}",
1560 file_path, line, character
1561 );
1562
1563 let target_line = (line + 1) as usize; let content = self.get_file_content(file_path)?;
1568
1569 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1571 debug!("Line content: {}", line_content);
1572
1573 let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1575 debug!("Word at cursor: {:?}", word_at_cursor);
1576
1577 let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1580
1581 if let Some(usages) = self.usages.get(file_path) {
1584 for usage in usages.iter() {
1585 if usage.line == target_line && usage.name == word_at_cursor {
1586 let cursor_pos = character as usize;
1588 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1589 debug!(
1590 "Cursor at {} is within usage range {}-{}: {}",
1591 cursor_pos, usage.start_char, usage.end_char, usage.name
1592 );
1593 info!("Found fixture usage at cursor position: {}", usage.name);
1594
1595 if let Some(ref current_def) = current_fixture_def {
1597 if current_def.name == word_at_cursor {
1598 info!(
1599 "Self-referencing fixture detected, finding parent definition"
1600 );
1601 return self.find_closest_definition_excluding(
1602 file_path,
1603 &usage.name,
1604 Some(current_def),
1605 );
1606 }
1607 }
1608
1609 return self.find_closest_definition(file_path, &usage.name);
1611 }
1612 }
1613 }
1614 }
1615
1616 debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1617 None
1618 }
1619
1620 fn get_fixture_definition_at_line(
1622 &self,
1623 file_path: &Path,
1624 line: usize,
1625 ) -> Option<FixtureDefinition> {
1626 for entry in self.definitions.iter() {
1627 for def in entry.value().iter() {
1628 if def.file_path == file_path && def.line == line {
1629 return Some(def.clone());
1630 }
1631 }
1632 }
1633 None
1634 }
1635
1636 pub fn get_definition_at_line(
1639 &self,
1640 file_path: &Path,
1641 line: usize,
1642 fixture_name: &str,
1643 ) -> Option<FixtureDefinition> {
1644 if let Some(definitions) = self.definitions.get(fixture_name) {
1645 for def in definitions.iter() {
1646 if def.file_path == file_path && def.line == line {
1647 return Some(def.clone());
1648 }
1649 }
1650 }
1651 None
1652 }
1653
1654 fn find_closest_definition(
1655 &self,
1656 file_path: &Path,
1657 fixture_name: &str,
1658 ) -> Option<FixtureDefinition> {
1659 let definitions = self.definitions.get(fixture_name)?;
1660
1661 debug!(
1664 "Checking for fixture {} in same file: {:?}",
1665 fixture_name, file_path
1666 );
1667
1668 if let Some(last_def) = definitions
1670 .iter()
1671 .filter(|def| def.file_path == file_path)
1672 .max_by_key(|def| def.line)
1673 {
1674 info!(
1675 "Found fixture {} in same file at line {} (using last definition)",
1676 fixture_name, last_def.line
1677 );
1678 return Some(last_def.clone());
1679 }
1680
1681 let mut current_dir = file_path.parent()?;
1684
1685 debug!(
1686 "Searching for fixture {} in conftest.py files starting from {:?}",
1687 fixture_name, current_dir
1688 );
1689 loop {
1690 let conftest_path = current_dir.join("conftest.py");
1692 debug!(" Checking conftest.py at: {:?}", conftest_path);
1693
1694 for def in definitions.iter() {
1695 if def.file_path == conftest_path {
1696 info!(
1697 "Found fixture {} in conftest.py: {:?}",
1698 fixture_name, conftest_path
1699 );
1700 return Some(def.clone());
1701 }
1702 }
1703
1704 match current_dir.parent() {
1706 Some(parent) => current_dir = parent,
1707 None => break,
1708 }
1709 }
1710
1711 debug!(
1714 "No fixture {} found in conftest hierarchy, checking for third-party fixtures",
1715 fixture_name
1716 );
1717 for def in definitions.iter() {
1718 if def.file_path.to_string_lossy().contains("site-packages") {
1719 info!(
1720 "Found third-party fixture {} in site-packages: {:?}",
1721 fixture_name, def.file_path
1722 );
1723 return Some(def.clone());
1724 }
1725 }
1726
1727 warn!(
1732 "No fixture {} found following priority rules (same file, conftest hierarchy, third-party)",
1733 fixture_name
1734 );
1735 warn!(
1736 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1737 );
1738
1739 let mut defs: Vec<_> = definitions.iter().cloned().collect();
1740 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1741 defs.first().cloned()
1742 }
1743
1744 fn find_closest_definition_excluding(
1747 &self,
1748 file_path: &Path,
1749 fixture_name: &str,
1750 exclude: Option<&FixtureDefinition>,
1751 ) -> Option<FixtureDefinition> {
1752 let definitions = self.definitions.get(fixture_name)?;
1753
1754 debug!(
1758 "Checking for fixture {} in same file: {:?} (excluding: {:?})",
1759 fixture_name, file_path, exclude
1760 );
1761
1762 if let Some(last_def) = definitions
1764 .iter()
1765 .filter(|def| {
1766 if def.file_path != file_path {
1767 return false;
1768 }
1769 if let Some(excluded) = exclude {
1771 if def == &excluded {
1772 debug!("Skipping excluded definition at line {}", def.line);
1773 return false;
1774 }
1775 }
1776 true
1777 })
1778 .max_by_key(|def| def.line)
1779 {
1780 info!(
1781 "Found fixture {} in same file at line {} (using last definition, excluding specified)",
1782 fixture_name, last_def.line
1783 );
1784 return Some(last_def.clone());
1785 }
1786
1787 let mut current_dir = file_path.parent()?;
1789
1790 debug!(
1791 "Searching for fixture {} in conftest.py files starting from {:?}",
1792 fixture_name, current_dir
1793 );
1794 loop {
1795 let conftest_path = current_dir.join("conftest.py");
1796 debug!(" Checking conftest.py at: {:?}", conftest_path);
1797
1798 for def in definitions.iter() {
1799 if def.file_path == conftest_path {
1800 if let Some(excluded) = exclude {
1802 if def == excluded {
1803 debug!("Skipping excluded definition at line {}", def.line);
1804 continue;
1805 }
1806 }
1807 info!(
1808 "Found fixture {} in conftest.py: {:?}",
1809 fixture_name, conftest_path
1810 );
1811 return Some(def.clone());
1812 }
1813 }
1814
1815 match current_dir.parent() {
1817 Some(parent) => current_dir = parent,
1818 None => break,
1819 }
1820 }
1821
1822 debug!(
1824 "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
1825 fixture_name
1826 );
1827 for def in definitions.iter() {
1828 if let Some(excluded) = exclude {
1830 if def == excluded {
1831 continue;
1832 }
1833 }
1834 if def.file_path.to_string_lossy().contains("site-packages") {
1835 info!(
1836 "Found third-party fixture {} in site-packages: {:?}",
1837 fixture_name, def.file_path
1838 );
1839 return Some(def.clone());
1840 }
1841 }
1842
1843 warn!(
1845 "No fixture {} found following priority rules (excluding specified)",
1846 fixture_name
1847 );
1848 warn!(
1849 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1850 );
1851
1852 let mut defs: Vec<_> = definitions
1853 .iter()
1854 .filter(|def| {
1855 if let Some(excluded) = exclude {
1856 def != &excluded
1857 } else {
1858 true
1859 }
1860 })
1861 .cloned()
1862 .collect();
1863 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1864 defs.first().cloned()
1865 }
1866
1867 pub fn find_fixture_at_position(
1869 &self,
1870 file_path: &Path,
1871 line: u32,
1872 character: u32,
1873 ) -> Option<String> {
1874 let target_line = (line + 1) as usize; debug!(
1877 "find_fixture_at_position: file={:?}, line={}, char={}",
1878 file_path, target_line, character
1879 );
1880
1881 let content = self.get_file_content(file_path)?;
1884
1885 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1887 debug!("Line content: {}", line_content);
1888
1889 let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
1891 debug!("Word at cursor: {:?}", word_at_cursor);
1892
1893 if let Some(usages) = self.usages.get(file_path) {
1896 for usage in usages.iter() {
1897 if usage.line == target_line {
1898 let cursor_pos = character as usize;
1900 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1901 debug!(
1902 "Cursor at {} is within usage range {}-{}: {}",
1903 cursor_pos, usage.start_char, usage.end_char, usage.name
1904 );
1905 info!("Found fixture usage at cursor position: {}", usage.name);
1906 return Some(usage.name.clone());
1907 }
1908 }
1909 }
1910 }
1911
1912 for entry in self.definitions.iter() {
1915 for def in entry.value().iter() {
1916 if def.file_path == file_path && def.line == target_line {
1917 if let Some(ref word) = word_at_cursor {
1919 if word == &def.name {
1920 info!(
1921 "Found fixture definition name at cursor position: {}",
1922 def.name
1923 );
1924 return Some(def.name.clone());
1925 }
1926 }
1927 }
1930 }
1931 }
1932
1933 debug!("No fixture found at cursor position");
1934 None
1935 }
1936
1937 pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
1938 let chars: Vec<char> = line.chars().collect();
1939
1940 if character > chars.len() {
1942 return None;
1943 }
1944
1945 if character < chars.len() {
1947 let c = chars[character];
1948 if c.is_alphanumeric() || c == '_' {
1949 let mut start = character;
1951 while start > 0 {
1952 let prev_c = chars[start - 1];
1953 if !prev_c.is_alphanumeric() && prev_c != '_' {
1954 break;
1955 }
1956 start -= 1;
1957 }
1958
1959 let mut end = character;
1960 while end < chars.len() {
1961 let curr_c = chars[end];
1962 if !curr_c.is_alphanumeric() && curr_c != '_' {
1963 break;
1964 }
1965 end += 1;
1966 }
1967
1968 if start < end {
1969 return Some(chars[start..end].iter().collect());
1970 }
1971 }
1972 }
1973
1974 None
1975 }
1976
1977 pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
1979 info!("Finding all references for fixture: {}", fixture_name);
1980
1981 let mut all_references = Vec::new();
1982
1983 for entry in self.usages.iter() {
1985 let file_path = entry.key();
1986 let usages = entry.value();
1987
1988 for usage in usages.iter() {
1990 if usage.name == fixture_name {
1991 debug!(
1992 "Found reference to {} in {:?} at line {}",
1993 fixture_name, file_path, usage.line
1994 );
1995 all_references.push(usage.clone());
1996 }
1997 }
1998 }
1999
2000 info!(
2001 "Found {} total references for fixture: {}",
2002 all_references.len(),
2003 fixture_name
2004 );
2005 all_references
2006 }
2007
2008 pub fn find_references_for_definition(
2015 &self,
2016 definition: &FixtureDefinition,
2017 ) -> Vec<FixtureUsage> {
2018 info!(
2019 "Finding references for specific definition: {} at {:?}:{}",
2020 definition.name, definition.file_path, definition.line
2021 );
2022
2023 let mut matching_references = Vec::new();
2024
2025 for entry in self.usages.iter() {
2027 let file_path = entry.key();
2028 let usages = entry.value();
2029
2030 for usage in usages.iter() {
2031 if usage.name == definition.name {
2032 let fixture_def_at_line =
2035 self.get_fixture_definition_at_line(file_path, usage.line);
2036
2037 let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
2038 if current_def.name == usage.name {
2039 debug!(
2041 "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
2042 file_path, usage.line, current_def.line
2043 );
2044 self.find_closest_definition_excluding(
2045 file_path,
2046 &usage.name,
2047 Some(current_def),
2048 )
2049 } else {
2050 self.find_closest_definition(file_path, &usage.name)
2052 }
2053 } else {
2054 self.find_closest_definition(file_path, &usage.name)
2056 };
2057
2058 if let Some(resolved_def) = resolved_def {
2059 if resolved_def == *definition {
2060 debug!(
2061 "Usage at {:?}:{} resolves to our definition",
2062 file_path, usage.line
2063 );
2064 matching_references.push(usage.clone());
2065 } else {
2066 debug!(
2067 "Usage at {:?}:{} resolves to different definition at {:?}:{}",
2068 file_path, usage.line, resolved_def.file_path, resolved_def.line
2069 );
2070 }
2071 }
2072 }
2073 }
2074 }
2075
2076 info!(
2077 "Found {} references that resolve to this specific definition",
2078 matching_references.len()
2079 );
2080 matching_references
2081 }
2082
2083 pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
2085 self.undeclared_fixtures
2086 .get(file_path)
2087 .map(|entry| entry.value().clone())
2088 .unwrap_or_default()
2089 }
2090
2091 pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
2094 let mut available_fixtures = Vec::new();
2095 let mut seen_names = std::collections::HashSet::new();
2096
2097 for entry in self.definitions.iter() {
2099 let fixture_name = entry.key();
2100 for def in entry.value().iter() {
2101 if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
2102 available_fixtures.push(def.clone());
2103 seen_names.insert(fixture_name.clone());
2104 }
2105 }
2106 }
2107
2108 if let Some(mut current_dir) = file_path.parent() {
2110 loop {
2111 let conftest_path = current_dir.join("conftest.py");
2112
2113 for entry in self.definitions.iter() {
2114 let fixture_name = entry.key();
2115 for def in entry.value().iter() {
2116 if def.file_path == conftest_path
2117 && !seen_names.contains(fixture_name.as_str())
2118 {
2119 available_fixtures.push(def.clone());
2120 seen_names.insert(fixture_name.clone());
2121 }
2122 }
2123 }
2124
2125 match current_dir.parent() {
2127 Some(parent) => current_dir = parent,
2128 None => break,
2129 }
2130 }
2131 }
2132
2133 for entry in self.definitions.iter() {
2135 let fixture_name = entry.key();
2136 for def in entry.value().iter() {
2137 if def.file_path.to_string_lossy().contains("site-packages")
2138 && !seen_names.contains(fixture_name.as_str())
2139 {
2140 available_fixtures.push(def.clone());
2141 seen_names.insert(fixture_name.clone());
2142 }
2143 }
2144 }
2145
2146 available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
2148 available_fixtures
2149 }
2150
2151 pub fn is_inside_function(
2154 &self,
2155 file_path: &Path,
2156 line: u32,
2157 character: u32,
2158 ) -> Option<(String, bool, Vec<String>)> {
2159 let content = self.get_file_content(file_path)?;
2161
2162 let target_line = (line + 1) as usize; let parsed = parse(&content, Mode::Module, "").ok()?;
2166
2167 if let rustpython_parser::ast::Mod::Module(module) = parsed {
2168 return self.find_enclosing_function(
2169 &module.body,
2170 &content,
2171 target_line,
2172 character as usize,
2173 );
2174 }
2175
2176 None
2177 }
2178
2179 fn find_enclosing_function(
2180 &self,
2181 stmts: &[Stmt],
2182 content: &str,
2183 target_line: usize,
2184 _target_char: usize,
2185 ) -> Option<(String, bool, Vec<String>)> {
2186 for stmt in stmts {
2187 match stmt {
2188 Stmt::FunctionDef(func_def) => {
2189 let func_start_line = content[..func_def.range.start().to_usize()]
2190 .matches('\n')
2191 .count()
2192 + 1;
2193 let func_end_line = content[..func_def.range.end().to_usize()]
2194 .matches('\n')
2195 .count()
2196 + 1;
2197
2198 if target_line >= func_start_line && target_line <= func_end_line {
2200 let is_fixture = func_def
2201 .decorator_list
2202 .iter()
2203 .any(Self::is_fixture_decorator);
2204 let is_test = func_def.name.starts_with("test_");
2205
2206 if is_test || is_fixture {
2208 let params: Vec<String> = func_def
2209 .args
2210 .args
2211 .iter()
2212 .map(|arg| arg.def.arg.to_string())
2213 .collect();
2214
2215 return Some((func_def.name.to_string(), is_fixture, params));
2216 }
2217 }
2218 }
2219 Stmt::AsyncFunctionDef(func_def) => {
2220 let func_start_line = content[..func_def.range.start().to_usize()]
2221 .matches('\n')
2222 .count()
2223 + 1;
2224 let func_end_line = content[..func_def.range.end().to_usize()]
2225 .matches('\n')
2226 .count()
2227 + 1;
2228
2229 if target_line >= func_start_line && target_line <= func_end_line {
2230 let is_fixture = func_def
2231 .decorator_list
2232 .iter()
2233 .any(Self::is_fixture_decorator);
2234 let is_test = func_def.name.starts_with("test_");
2235
2236 if is_test || is_fixture {
2237 let params: Vec<String> = func_def
2238 .args
2239 .args
2240 .iter()
2241 .map(|arg| arg.def.arg.to_string())
2242 .collect();
2243
2244 return Some((func_def.name.to_string(), is_fixture, params));
2245 }
2246 }
2247 }
2248 _ => {}
2249 }
2250 }
2251
2252 None
2253 }
2254}