1use dashmap::DashMap;
2use rustpython_parser::ast::{ArgWithDefault, Arguments, 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, error, 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 pub canonical_path_cache: Arc<DashMap<PathBuf, PathBuf>>,
53}
54
55impl Default for FixtureDatabase {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl FixtureDatabase {
62 pub fn new() -> Self {
63 Self {
64 definitions: Arc::new(DashMap::new()),
65 usages: Arc::new(DashMap::new()),
66 file_cache: Arc::new(DashMap::new()),
67 undeclared_fixtures: Arc::new(DashMap::new()),
68 imports: Arc::new(DashMap::new()),
69 canonical_path_cache: Arc::new(DashMap::new()),
70 }
71 }
72
73 fn get_canonical_path(&self, path: PathBuf) -> PathBuf {
76 if let Some(cached) = self.canonical_path_cache.get(&path) {
78 return cached.value().clone();
79 }
80
81 let canonical = path.canonicalize().unwrap_or_else(|_| {
83 debug!("Could not canonicalize path {:?}, using as-is", path);
84 path.clone()
85 });
86
87 self.canonical_path_cache.insert(path, canonical.clone());
89 canonical
90 }
91
92 fn get_file_content(&self, file_path: &Path) -> Option<Arc<String>> {
95 if let Some(cached) = self.file_cache.get(file_path) {
96 Some(Arc::clone(cached.value()))
97 } else {
98 std::fs::read_to_string(file_path).ok().map(Arc::new)
99 }
100 }
101
102 const SKIP_DIRECTORIES: &'static [&'static str] = &[
105 ".git",
107 ".hg",
108 ".svn",
109 ".venv",
111 "venv",
112 "env",
113 ".env",
114 "__pycache__",
116 ".pytest_cache",
117 ".mypy_cache",
118 ".ruff_cache",
119 ".tox",
120 ".nox",
121 "build",
122 "dist",
123 ".eggs",
124 "node_modules",
126 "bower_components",
127 "target",
129 ".idea",
131 ".vscode",
132 ".cache",
134 ".local",
135 "vendor",
136 "site-packages",
137 ];
138
139 fn should_skip_directory(dir_name: &str) -> bool {
141 if Self::SKIP_DIRECTORIES.contains(&dir_name) {
143 return true;
144 }
145 if dir_name.ends_with(".egg-info") {
147 return true;
148 }
149 false
150 }
151
152 pub fn scan_workspace(&self, root_path: &Path) {
154 info!("Scanning workspace: {:?}", root_path);
155
156 if !root_path.exists() {
158 warn!(
159 "Workspace path does not exist, skipping scan: {:?}",
160 root_path
161 );
162 return;
163 }
164 let mut file_count = 0;
165 let mut error_count = 0;
166 let mut skipped_dirs = 0;
167
168 let walker = WalkDir::new(root_path).into_iter().filter_entry(|entry| {
170 if entry.file_type().is_file() {
172 return true;
173 }
174 if let Some(dir_name) = entry.file_name().to_str() {
176 !Self::should_skip_directory(dir_name)
177 } else {
178 true
179 }
180 });
181
182 for entry in walker {
183 let entry = match entry {
184 Ok(e) => e,
185 Err(err) => {
186 if err
188 .io_error()
189 .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
190 {
191 warn!(
192 "Permission denied accessing path during workspace scan: {}",
193 err
194 );
195 } else {
196 debug!("Error during workspace scan: {}", err);
197 error_count += 1;
198 }
199 continue;
200 }
201 };
202
203 let path = entry.path();
204
205 if path.components().any(|c| {
207 c.as_os_str()
208 .to_str()
209 .is_some_and(Self::should_skip_directory)
210 }) {
211 skipped_dirs += 1;
212 continue;
213 }
214
215 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
217 if filename == "conftest.py"
218 || filename.starts_with("test_") && filename.ends_with(".py")
219 || filename.ends_with("_test.py")
220 {
221 debug!("Found test/conftest file: {:?}", path);
222 match std::fs::read_to_string(path) {
223 Ok(content) => {
224 self.analyze_file(path.to_path_buf(), &content);
225 file_count += 1;
226 }
227 Err(err) => {
228 if err.kind() == std::io::ErrorKind::PermissionDenied {
229 warn!("Permission denied reading file: {:?}", path);
230 } else {
231 error!("Failed to read file {:?}: {}", path, err);
232 error_count += 1;
233 }
234 }
235 }
236 }
237 }
238 }
239
240 if skipped_dirs > 0 {
241 debug!("Skipped {} entries in filtered directories", skipped_dirs);
242 }
243
244 if error_count > 0 {
245 warn!("Workspace scan completed with {} errors", error_count);
246 }
247
248 info!("Workspace scan complete. Processed {} files", file_count);
249
250 self.scan_venv_fixtures(root_path);
252
253 info!("Total fixtures defined: {}", self.definitions.len());
254 info!("Total files with fixture usages: {}", self.usages.len());
255 }
256
257 fn scan_venv_fixtures(&self, root_path: &Path) {
259 info!("Scanning for pytest plugins in virtual environment");
260
261 let venv_paths = vec![
263 root_path.join(".venv"),
264 root_path.join("venv"),
265 root_path.join("env"),
266 ];
267
268 info!("Checking for venv in: {:?}", root_path);
269 for venv_path in &venv_paths {
270 debug!("Checking venv path: {:?}", venv_path);
271 if venv_path.exists() {
272 info!("Found virtual environment at: {:?}", venv_path);
273 self.scan_venv_site_packages(venv_path);
274 return;
275 } else {
276 debug!(" Does not exist: {:?}", venv_path);
277 }
278 }
279
280 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
282 info!("Found VIRTUAL_ENV environment variable: {}", venv);
283 let venv_path = PathBuf::from(venv);
284 if venv_path.exists() {
285 info!("Using VIRTUAL_ENV: {:?}", venv_path);
286 self.scan_venv_site_packages(&venv_path);
287 return;
288 } else {
289 warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
290 }
291 } else {
292 debug!("No VIRTUAL_ENV environment variable set");
293 }
294
295 warn!("No virtual environment found - third-party fixtures will not be available");
296 }
297
298 fn scan_venv_site_packages(&self, venv_path: &Path) {
299 info!("Scanning venv site-packages in: {:?}", venv_path);
300
301 let lib_path = venv_path.join("lib");
303 debug!("Checking lib path: {:?}", lib_path);
304
305 if lib_path.exists() {
306 if let Ok(entries) = std::fs::read_dir(&lib_path) {
308 for entry in entries.flatten() {
309 let path = entry.path();
310 let dirname = path.file_name().unwrap_or_default().to_string_lossy();
311 debug!("Found in lib: {:?}", dirname);
312
313 if path.is_dir() && dirname.starts_with("python") {
314 let site_packages = path.join("site-packages");
315 debug!("Checking site-packages: {:?}", site_packages);
316
317 if site_packages.exists() {
318 info!("Found site-packages: {:?}", site_packages);
319 self.scan_pytest_plugins(&site_packages);
320 return;
321 }
322 }
323 }
324 }
325 }
326
327 let windows_site_packages = venv_path.join("Lib/site-packages");
329 debug!("Checking Windows path: {:?}", windows_site_packages);
330 if windows_site_packages.exists() {
331 info!("Found site-packages (Windows): {:?}", windows_site_packages);
332 self.scan_pytest_plugins(&windows_site_packages);
333 return;
334 }
335
336 warn!("Could not find site-packages in venv: {:?}", venv_path);
337 }
338
339 fn scan_pytest_plugins(&self, site_packages: &Path) {
340 info!("Scanning pytest plugins in: {:?}", site_packages);
341
342 let pytest_packages = vec![
344 "pytest_mock",
346 "pytest-mock",
347 "pytest_asyncio",
348 "pytest-asyncio",
349 "pytest_django",
350 "pytest-django",
351 "pytest_cov",
352 "pytest-cov",
353 "pytest_xdist",
354 "pytest-xdist",
355 "pytest_fixtures",
356 "pytest_flask",
358 "pytest-flask",
359 "pytest_httpx",
360 "pytest-httpx",
361 "pytest_postgresql",
362 "pytest-postgresql",
363 "pytest_mongodb",
364 "pytest-mongodb",
365 "pytest_redis",
366 "pytest-redis",
367 "pytest_elasticsearch",
368 "pytest-elasticsearch",
369 "pytest_rabbitmq",
370 "pytest-rabbitmq",
371 "pytest_mysql",
372 "pytest-mysql",
373 "pytest_docker",
374 "pytest-docker",
375 "pytest_kubernetes",
376 "pytest-kubernetes",
377 "pytest_celery",
378 "pytest-celery",
379 "pytest_tornado",
380 "pytest-tornado",
381 "pytest_aiohttp",
382 "pytest-aiohttp",
383 "pytest_sanic",
384 "pytest-sanic",
385 "pytest_fastapi",
386 "pytest-fastapi",
387 "pytest_alembic",
388 "pytest-alembic",
389 "pytest_sqlalchemy",
390 "pytest-sqlalchemy",
391 "pytest_factoryboy",
392 "pytest-factoryboy",
393 "pytest_freezegun",
394 "pytest-freezegun",
395 "pytest_mimesis",
396 "pytest-mimesis",
397 "pytest_lazy_fixture",
398 "pytest-lazy-fixture",
399 "pytest_cases",
400 "pytest-cases",
401 "pytest_bdd",
402 "pytest-bdd",
403 "pytest_benchmark",
404 "pytest-benchmark",
405 "pytest_timeout",
406 "pytest-timeout",
407 "pytest_retry",
408 "pytest-retry",
409 "pytest_repeat",
410 "pytest-repeat",
411 "pytest_rerunfailures",
412 "pytest-rerunfailures",
413 "pytest_ordering",
414 "pytest-ordering",
415 "pytest_dependency",
416 "pytest-dependency",
417 "pytest_random_order",
418 "pytest-random-order",
419 "pytest_picked",
420 "pytest-picked",
421 "pytest_testmon",
422 "pytest-testmon",
423 "pytest_split",
424 "pytest-split",
425 "pytest_env",
426 "pytest-env",
427 "pytest_dotenv",
428 "pytest-dotenv",
429 "pytest_html",
430 "pytest-html",
431 "pytest_json_report",
432 "pytest-json-report",
433 "pytest_metadata",
434 "pytest-metadata",
435 "pytest_instafail",
436 "pytest-instafail",
437 "pytest_clarity",
438 "pytest-clarity",
439 "pytest_sugar",
440 "pytest-sugar",
441 "pytest_emoji",
442 "pytest-emoji",
443 "pytest_play",
444 "pytest-play",
445 "pytest_selenium",
446 "pytest-selenium",
447 "pytest_playwright",
448 "pytest-playwright",
449 "pytest_splinter",
450 "pytest-splinter",
451 ];
452
453 let mut plugin_count = 0;
454
455 for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
456 let entry = match entry {
457 Ok(e) => e,
458 Err(_) => continue,
459 };
460
461 let path = entry.path();
462 let filename = path.file_name().unwrap_or_default().to_string_lossy();
463
464 let is_pytest_package = pytest_packages.iter().any(|pkg| filename.contains(pkg))
466 || filename.starts_with("pytest")
467 || filename.contains("_pytest");
468
469 if is_pytest_package && path.is_dir() {
470 if filename.ends_with(".dist-info") || filename.ends_with(".egg-info") {
472 debug!("Skipping dist-info directory: {:?}", filename);
473 continue;
474 }
475
476 info!("Scanning pytest plugin: {:?}", path);
477 plugin_count += 1;
478 self.scan_plugin_directory(&path);
479 } else {
480 if filename.contains("mock") {
482 debug!("Found mock-related package (not scanning): {:?}", filename);
483 }
484 }
485 }
486
487 info!("Scanned {} pytest plugin packages", plugin_count);
488 }
489
490 fn scan_plugin_directory(&self, plugin_dir: &Path) {
491 for entry in WalkDir::new(plugin_dir)
493 .max_depth(3) .into_iter()
495 .filter_map(|e| e.ok())
496 {
497 let path = entry.path();
498
499 if path.extension().and_then(|s| s.to_str()) == Some("py") {
500 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
502 if filename.starts_with("test_") || filename.contains("__pycache__") {
504 continue;
505 }
506
507 debug!("Scanning plugin file: {:?}", path);
508 if let Ok(content) = std::fs::read_to_string(path) {
509 self.analyze_file(path.to_path_buf(), &content);
510 }
511 }
512 }
513 }
514 }
515
516 pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
518 let file_path = self.get_canonical_path(file_path);
520
521 debug!("Analyzing file: {:?}", file_path);
522
523 self.file_cache
526 .insert(file_path.clone(), Arc::new(content.to_string()));
527
528 let parsed = match parse(content, Mode::Module, "") {
530 Ok(ast) => ast,
531 Err(e) => {
532 error!("Failed to parse Python file {:?}: {}", file_path, e);
533 return;
534 }
535 };
536
537 self.usages.remove(&file_path);
539
540 self.undeclared_fixtures.remove(&file_path);
542
543 self.imports.remove(&file_path);
545
546 let keys: Vec<String> = {
554 let mut k = Vec::new();
555 for entry in self.definitions.iter() {
556 k.push(entry.key().clone());
557 }
558 k
559 }; for key in keys {
563 let current_defs = match self.definitions.get(&key) {
565 Some(defs) => defs.clone(),
566 None => continue,
567 };
568
569 let filtered: Vec<FixtureDefinition> = current_defs
571 .iter()
572 .filter(|def| def.file_path != file_path)
573 .cloned()
574 .collect();
575
576 if filtered.is_empty() {
578 self.definitions.remove(&key);
579 } else if filtered.len() != current_defs.len() {
580 self.definitions.insert(key, filtered);
582 }
583 }
584
585 let is_conftest = file_path
587 .file_name()
588 .map(|n| n == "conftest.py")
589 .unwrap_or(false);
590 debug!("is_conftest: {}", is_conftest);
591
592 let line_index = Self::build_line_index(content);
594
595 if let rustpython_parser::ast::Mod::Module(module) = parsed {
597 debug!("Module has {} statements", module.body.len());
598
599 let mut module_level_names = std::collections::HashSet::new();
601 for stmt in &module.body {
602 self.collect_module_level_names(stmt, &mut module_level_names);
603 }
604 self.imports.insert(file_path.clone(), module_level_names);
605
606 for stmt in &module.body {
608 self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
609 }
610 }
611
612 debug!("Analysis complete for {:?}", file_path);
613 }
614
615 fn visit_stmt(
616 &self,
617 stmt: &Stmt,
618 file_path: &PathBuf,
619 _is_conftest: bool,
620 content: &str,
621 line_index: &[usize],
622 ) {
623 if let Stmt::Assign(assign) = stmt {
625 self.visit_assignment_fixture(assign, file_path, content, line_index);
626 }
627
628 if let Stmt::ClassDef(class_def) = stmt {
630 for decorator in &class_def.decorator_list {
632 let usefixtures = Self::extract_usefixtures_names(decorator);
633 for (fixture_name, range) in usefixtures {
634 let usage_line =
635 self.get_line_from_offset(range.start().to_usize(), line_index);
636 let start_char =
637 self.get_char_position_from_offset(range.start().to_usize(), line_index);
638 let end_char =
640 self.get_char_position_from_offset(range.end().to_usize(), line_index);
641
642 info!(
643 "Found usefixtures usage on class: {} at {:?}:{}:{}",
644 fixture_name, file_path, usage_line, start_char
645 );
646
647 let usage = FixtureUsage {
648 name: fixture_name,
649 file_path: file_path.clone(),
650 line: usage_line,
651 start_char: start_char + 1, end_char: end_char - 1, };
654
655 self.usages
656 .entry(file_path.clone())
657 .or_default()
658 .push(usage);
659 }
660 }
661
662 for class_stmt in &class_def.body {
663 self.visit_stmt(class_stmt, file_path, _is_conftest, content, line_index);
664 }
665 return;
666 }
667
668 let (func_name, decorator_list, args, range, body, returns) = match stmt {
670 Stmt::FunctionDef(func_def) => (
671 func_def.name.as_str(),
672 &func_def.decorator_list,
673 &func_def.args,
674 func_def.range,
675 &func_def.body,
676 &func_def.returns,
677 ),
678 Stmt::AsyncFunctionDef(func_def) => (
679 func_def.name.as_str(),
680 &func_def.decorator_list,
681 &func_def.args,
682 func_def.range,
683 &func_def.body,
684 &func_def.returns,
685 ),
686 _ => return,
687 };
688
689 debug!("Found function: {}", func_name);
690
691 for decorator in decorator_list {
693 let usefixtures = Self::extract_usefixtures_names(decorator);
694 for (fixture_name, range) in usefixtures {
695 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
696 let start_char =
697 self.get_char_position_from_offset(range.start().to_usize(), line_index);
698 let end_char =
699 self.get_char_position_from_offset(range.end().to_usize(), line_index);
700
701 info!(
702 "Found usefixtures usage on function: {} at {:?}:{}:{}",
703 fixture_name, file_path, usage_line, start_char
704 );
705
706 let usage = FixtureUsage {
707 name: fixture_name,
708 file_path: file_path.clone(),
709 line: usage_line,
710 start_char: start_char + 1, end_char: end_char - 1, };
713
714 self.usages
715 .entry(file_path.clone())
716 .or_default()
717 .push(usage);
718 }
719 }
720
721 for decorator in decorator_list {
723 let indirect_fixtures = Self::extract_parametrize_indirect_fixtures(decorator);
724 for (fixture_name, range) in indirect_fixtures {
725 let usage_line = self.get_line_from_offset(range.start().to_usize(), line_index);
726 let start_char =
727 self.get_char_position_from_offset(range.start().to_usize(), line_index);
728 let end_char =
729 self.get_char_position_from_offset(range.end().to_usize(), line_index);
730
731 info!(
732 "Found parametrize indirect fixture usage: {} at {:?}:{}:{}",
733 fixture_name, file_path, usage_line, start_char
734 );
735
736 let usage = FixtureUsage {
737 name: fixture_name,
738 file_path: file_path.clone(),
739 line: usage_line,
740 start_char: start_char + 1, end_char: end_char - 1, };
743
744 self.usages
745 .entry(file_path.clone())
746 .or_default()
747 .push(usage);
748 }
749 }
750
751 debug!(
753 "Function {} has {} decorators",
754 func_name,
755 decorator_list.len()
756 );
757 let fixture_decorator = decorator_list
759 .iter()
760 .find(|dec| Self::is_fixture_decorator(dec));
761
762 if let Some(decorator) = fixture_decorator {
763 debug!(" Decorator matched as fixture!");
764
765 let fixture_name = Self::extract_fixture_name_from_decorator(decorator)
767 .unwrap_or_else(|| func_name.to_string());
768
769 let line = self.get_line_from_offset(range.start().to_usize(), line_index);
771
772 let docstring = self.extract_docstring(body);
774
775 let return_type = self.extract_return_type(returns, body, content);
777
778 info!(
779 "Found fixture definition: {} (function: {}) at {:?}:{}",
780 fixture_name, func_name, file_path, line
781 );
782 if let Some(ref doc) = docstring {
783 debug!(" Docstring: {}", doc);
784 }
785 if let Some(ref ret_type) = return_type {
786 debug!(" Return type: {}", ret_type);
787 }
788
789 let definition = FixtureDefinition {
790 name: fixture_name.clone(),
791 file_path: file_path.clone(),
792 line,
793 docstring,
794 return_type,
795 };
796
797 self.definitions
798 .entry(fixture_name)
799 .or_default()
800 .push(definition);
801
802 let mut declared_params: std::collections::HashSet<String> =
804 std::collections::HashSet::new();
805 declared_params.insert("self".to_string());
806 declared_params.insert("request".to_string());
807 declared_params.insert(func_name.to_string()); for arg in Self::all_args(args) {
811 let arg_name = arg.def.arg.as_str();
812 declared_params.insert(arg_name.to_string());
813
814 if arg_name != "self" && arg_name != "request" {
815 let arg_line =
818 self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
819 let start_char = self.get_char_position_from_offset(
820 arg.def.range.start().to_usize(),
821 line_index,
822 );
823 let end_char = self
824 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
825
826 info!(
827 "Found fixture dependency: {} at {:?}:{}:{}",
828 arg_name, file_path, arg_line, start_char
829 );
830
831 let usage = FixtureUsage {
832 name: arg_name.to_string(),
833 file_path: file_path.clone(),
834 line: arg_line, start_char,
836 end_char,
837 };
838
839 self.usages
840 .entry(file_path.clone())
841 .or_default()
842 .push(usage);
843 }
844 }
845
846 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
848 self.scan_function_body_for_undeclared_fixtures(
849 body,
850 file_path,
851 content,
852 line_index,
853 &declared_params,
854 func_name,
855 function_line,
856 );
857 }
858
859 let is_test = func_name.starts_with("test_");
861
862 if is_test {
863 debug!("Found test function: {}", func_name);
864
865 let mut declared_params: std::collections::HashSet<String> =
867 std::collections::HashSet::new();
868 declared_params.insert("self".to_string());
869 declared_params.insert("request".to_string()); for arg in Self::all_args(args) {
874 let arg_name = arg.def.arg.as_str();
875 declared_params.insert(arg_name.to_string());
876
877 if arg_name != "self" {
878 let arg_offset = arg.def.range.start().to_usize();
882 let arg_line = self.get_line_from_offset(arg_offset, line_index);
883 let start_char = self.get_char_position_from_offset(arg_offset, line_index);
884 let end_char = self
885 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
886
887 debug!(
888 "Parameter {} at offset {}, calculated line {}, char {}",
889 arg_name, arg_offset, arg_line, start_char
890 );
891 info!(
892 "Found fixture usage: {} at {:?}:{}:{}",
893 arg_name, file_path, arg_line, start_char
894 );
895
896 let usage = FixtureUsage {
897 name: arg_name.to_string(),
898 file_path: file_path.clone(),
899 line: arg_line, start_char,
901 end_char,
902 };
903
904 self.usages
906 .entry(file_path.clone())
907 .or_default()
908 .push(usage);
909 }
910 }
911
912 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
914 self.scan_function_body_for_undeclared_fixtures(
915 body,
916 file_path,
917 content,
918 line_index,
919 &declared_params,
920 func_name,
921 function_line,
922 );
923 }
924 }
925
926 fn visit_assignment_fixture(
927 &self,
928 assign: &rustpython_parser::ast::StmtAssign,
929 file_path: &PathBuf,
930 _content: &str,
931 line_index: &[usize],
932 ) {
933 if let Expr::Call(outer_call) = &*assign.value {
937 if let Expr::Call(inner_call) = &*outer_call.func {
939 if Self::is_fixture_decorator(&inner_call.func) {
940 for target in &assign.targets {
943 if let Expr::Name(name) = target {
944 let fixture_name = name.id.as_str();
945 let line = self
946 .get_line_from_offset(assign.range.start().to_usize(), line_index);
947
948 info!(
949 "Found fixture assignment: {} at {:?}:{}",
950 fixture_name, file_path, line
951 );
952
953 let definition = FixtureDefinition {
955 name: fixture_name.to_string(),
956 file_path: file_path.clone(),
957 line,
958 docstring: None,
959 return_type: None,
960 };
961
962 self.definitions
963 .entry(fixture_name.to_string())
964 .or_default()
965 .push(definition);
966 }
967 }
968 }
969 }
970 }
971 }
972
973 fn all_args(args: &Arguments) -> impl Iterator<Item = &ArgWithDefault> {
977 args.posonlyargs
978 .iter()
979 .chain(args.args.iter())
980 .chain(args.kwonlyargs.iter())
981 }
982
983 fn is_fixture_decorator(expr: &Expr) -> bool {
984 match expr {
985 Expr::Name(name) => name.id.as_str() == "fixture",
986 Expr::Attribute(attr) => {
987 if let Expr::Name(value) = &*attr.value {
989 value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
990 } else {
991 false
992 }
993 }
994 Expr::Call(call) => {
995 Self::is_fixture_decorator(&call.func)
997 }
998 _ => false,
999 }
1000 }
1001
1002 fn extract_fixture_name_from_decorator(expr: &Expr) -> Option<String> {
1004 let Expr::Call(call) = expr else { return None };
1005 if !Self::is_fixture_decorator(&call.func) {
1006 return None;
1007 }
1008
1009 call.keywords
1010 .iter()
1011 .filter(|kw| kw.arg.as_ref().is_some_and(|a| a.as_str() == "name"))
1012 .find_map(|kw| match &kw.value {
1013 Expr::Constant(c) => match &c.value {
1014 rustpython_parser::ast::Constant::Str(s) => Some(s.to_string()),
1015 _ => None,
1016 },
1017 _ => None,
1018 })
1019 }
1020
1021 fn is_usefixtures_decorator(expr: &Expr) -> bool {
1024 match expr {
1025 Expr::Call(call) => Self::is_usefixtures_decorator(&call.func),
1026 Expr::Attribute(attr) => {
1027 if attr.attr.as_str() != "usefixtures" {
1029 return false;
1030 }
1031 match &*attr.value {
1032 Expr::Attribute(inner_attr) => {
1034 if inner_attr.attr.as_str() != "mark" {
1035 return false;
1036 }
1037 matches!(&*inner_attr.value, Expr::Name(name) if name.id.as_str() == "pytest")
1038 }
1039 Expr::Name(name) => name.id.as_str() == "mark",
1041 _ => false,
1042 }
1043 }
1044 _ => false,
1045 }
1046 }
1047
1048 fn extract_usefixtures_names(
1051 expr: &Expr,
1052 ) -> Vec<(String, rustpython_parser::text_size::TextRange)> {
1053 let Expr::Call(call) = expr else {
1054 return vec![];
1055 };
1056 if !Self::is_usefixtures_decorator(&call.func) {
1057 return vec![];
1058 }
1059
1060 call.args
1061 .iter()
1062 .filter_map(|arg| {
1063 if let Expr::Constant(c) = arg {
1064 if let rustpython_parser::ast::Constant::Str(s) = &c.value {
1065 return Some((s.to_string(), c.range));
1066 }
1067 }
1068 None
1069 })
1070 .collect()
1071 }
1072
1073 fn is_parametrize_decorator(expr: &Expr) -> bool {
1075 match expr {
1076 Expr::Call(call) => Self::is_parametrize_decorator(&call.func),
1077 Expr::Attribute(attr) => {
1078 if attr.attr.as_str() != "parametrize" {
1079 return false;
1080 }
1081 match &*attr.value {
1082 Expr::Attribute(inner_attr) => {
1084 if inner_attr.attr.as_str() != "mark" {
1085 return false;
1086 }
1087 matches!(&*inner_attr.value, Expr::Name(name) if name.id.as_str() == "pytest")
1088 }
1089 Expr::Name(name) => name.id.as_str() == "mark",
1091 _ => false,
1092 }
1093 }
1094 _ => false,
1095 }
1096 }
1097
1098 fn extract_parametrize_indirect_fixtures(
1106 expr: &Expr,
1107 ) -> Vec<(String, rustpython_parser::text_size::TextRange)> {
1108 let Expr::Call(call) = expr else {
1109 return vec![];
1110 };
1111 if !Self::is_parametrize_decorator(&call.func) {
1112 return vec![];
1113 }
1114
1115 let indirect_value = call.keywords.iter().find_map(|kw| {
1117 if kw.arg.as_ref().is_some_and(|a| a.as_str() == "indirect") {
1118 Some(&kw.value)
1119 } else {
1120 None
1121 }
1122 });
1123
1124 let Some(indirect) = indirect_value else {
1125 return vec![];
1126 };
1127
1128 let Some(first_arg) = call.args.first() else {
1130 return vec![];
1131 };
1132
1133 let Expr::Constant(param_const) = first_arg else {
1134 return vec![];
1135 };
1136
1137 let rustpython_parser::ast::Constant::Str(param_str) = ¶m_const.value else {
1138 return vec![];
1139 };
1140
1141 let param_names: Vec<&str> = param_str.split(',').map(|s| s.trim()).collect();
1143
1144 match indirect {
1145 Expr::Constant(c) => {
1147 if matches!(c.value, rustpython_parser::ast::Constant::Bool(true)) {
1148 return param_names
1149 .into_iter()
1150 .map(|name| (name.to_string(), param_const.range))
1151 .collect();
1152 }
1153 }
1154 Expr::List(list) => {
1156 return list
1157 .elts
1158 .iter()
1159 .filter_map(|elt| {
1160 if let Expr::Constant(c) = elt {
1161 if let rustpython_parser::ast::Constant::Str(s) = &c.value {
1162 if param_names.contains(&s.as_str()) {
1163 return Some((s.to_string(), c.range));
1164 }
1165 }
1166 }
1167 None
1168 })
1169 .collect();
1170 }
1171 _ => {}
1172 }
1173
1174 vec![]
1175 }
1176
1177 #[allow(clippy::too_many_arguments)]
1178 fn scan_function_body_for_undeclared_fixtures(
1179 &self,
1180 body: &[Stmt],
1181 file_path: &PathBuf,
1182 content: &str,
1183 line_index: &[usize],
1184 declared_params: &std::collections::HashSet<String>,
1185 function_name: &str,
1186 function_line: usize,
1187 ) {
1188 let mut local_vars = std::collections::HashMap::new();
1190 self.collect_local_variables(body, content, line_index, &mut local_vars);
1191
1192 if let Some(imports) = self.imports.get(file_path) {
1195 for import in imports.iter() {
1196 local_vars.insert(import.clone(), 0);
1197 }
1198 }
1199
1200 for stmt in body {
1202 self.visit_stmt_for_names(
1203 stmt,
1204 file_path,
1205 content,
1206 line_index,
1207 declared_params,
1208 &local_vars,
1209 function_name,
1210 function_line,
1211 );
1212 }
1213 }
1214
1215 fn collect_module_level_names(
1216 &self,
1217 stmt: &Stmt,
1218 names: &mut std::collections::HashSet<String>,
1219 ) {
1220 match stmt {
1221 Stmt::Import(import_stmt) => {
1223 for alias in &import_stmt.names {
1224 let name = alias.asname.as_ref().unwrap_or(&alias.name);
1226 names.insert(name.to_string());
1227 }
1228 }
1229 Stmt::ImportFrom(import_from) => {
1230 for alias in &import_from.names {
1231 let name = alias.asname.as_ref().unwrap_or(&alias.name);
1233 names.insert(name.to_string());
1234 }
1235 }
1236 Stmt::FunctionDef(func_def) => {
1238 let is_fixture = func_def
1240 .decorator_list
1241 .iter()
1242 .any(Self::is_fixture_decorator);
1243 if !is_fixture {
1244 names.insert(func_def.name.to_string());
1245 }
1246 }
1247 Stmt::AsyncFunctionDef(func_def) => {
1249 let is_fixture = func_def
1250 .decorator_list
1251 .iter()
1252 .any(Self::is_fixture_decorator);
1253 if !is_fixture {
1254 names.insert(func_def.name.to_string());
1255 }
1256 }
1257 Stmt::ClassDef(class_def) => {
1259 names.insert(class_def.name.to_string());
1260 }
1261 Stmt::Assign(assign) => {
1263 for target in &assign.targets {
1264 self.collect_names_from_expr(target, names);
1265 }
1266 }
1267 Stmt::AnnAssign(ann_assign) => {
1268 self.collect_names_from_expr(&ann_assign.target, names);
1269 }
1270 _ => {}
1271 }
1272 }
1273
1274 #[allow(clippy::only_used_in_recursion)]
1275 fn collect_local_variables(
1276 &self,
1277 body: &[Stmt],
1278 content: &str,
1279 line_index: &[usize],
1280 local_vars: &mut std::collections::HashMap<String, usize>,
1281 ) {
1282 for stmt in body {
1283 match stmt {
1284 Stmt::Assign(assign) => {
1285 let line =
1287 self.get_line_from_offset(assign.range.start().to_usize(), line_index);
1288 let mut temp_names = std::collections::HashSet::new();
1289 for target in &assign.targets {
1290 self.collect_names_from_expr(target, &mut temp_names);
1291 }
1292 for name in temp_names {
1293 local_vars.insert(name, line);
1294 }
1295 }
1296 Stmt::AnnAssign(ann_assign) => {
1297 let line =
1299 self.get_line_from_offset(ann_assign.range.start().to_usize(), line_index);
1300 let mut temp_names = std::collections::HashSet::new();
1301 self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
1302 for name in temp_names {
1303 local_vars.insert(name, line);
1304 }
1305 }
1306 Stmt::AugAssign(aug_assign) => {
1307 let line =
1309 self.get_line_from_offset(aug_assign.range.start().to_usize(), line_index);
1310 let mut temp_names = std::collections::HashSet::new();
1311 self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
1312 for name in temp_names {
1313 local_vars.insert(name, line);
1314 }
1315 }
1316 Stmt::For(for_stmt) => {
1317 let line =
1319 self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
1320 let mut temp_names = std::collections::HashSet::new();
1321 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
1322 for name in temp_names {
1323 local_vars.insert(name, line);
1324 }
1325 self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
1327 }
1328 Stmt::AsyncFor(for_stmt) => {
1329 let line =
1330 self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
1331 let mut temp_names = std::collections::HashSet::new();
1332 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
1333 for name in temp_names {
1334 local_vars.insert(name, line);
1335 }
1336 self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
1337 }
1338 Stmt::While(while_stmt) => {
1339 self.collect_local_variables(&while_stmt.body, content, line_index, local_vars);
1340 }
1341 Stmt::If(if_stmt) => {
1342 self.collect_local_variables(&if_stmt.body, content, line_index, local_vars);
1343 self.collect_local_variables(&if_stmt.orelse, content, line_index, local_vars);
1344 }
1345 Stmt::With(with_stmt) => {
1346 let line =
1348 self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
1349 for item in &with_stmt.items {
1350 if let Some(ref optional_vars) = item.optional_vars {
1351 let mut temp_names = std::collections::HashSet::new();
1352 self.collect_names_from_expr(optional_vars, &mut temp_names);
1353 for name in temp_names {
1354 local_vars.insert(name, line);
1355 }
1356 }
1357 }
1358 self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
1359 }
1360 Stmt::AsyncWith(with_stmt) => {
1361 let line =
1362 self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
1363 for item in &with_stmt.items {
1364 if let Some(ref optional_vars) = item.optional_vars {
1365 let mut temp_names = std::collections::HashSet::new();
1366 self.collect_names_from_expr(optional_vars, &mut temp_names);
1367 for name in temp_names {
1368 local_vars.insert(name, line);
1369 }
1370 }
1371 }
1372 self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
1373 }
1374 Stmt::Try(try_stmt) => {
1375 self.collect_local_variables(&try_stmt.body, content, line_index, local_vars);
1376 self.collect_local_variables(&try_stmt.orelse, content, line_index, local_vars);
1380 self.collect_local_variables(
1381 &try_stmt.finalbody,
1382 content,
1383 line_index,
1384 local_vars,
1385 );
1386 }
1387 _ => {}
1388 }
1389 }
1390 }
1391
1392 #[allow(clippy::only_used_in_recursion)]
1393 fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
1394 match expr {
1395 Expr::Name(name) => {
1396 names.insert(name.id.to_string());
1397 }
1398 Expr::Tuple(tuple) => {
1399 for elt in &tuple.elts {
1400 self.collect_names_from_expr(elt, names);
1401 }
1402 }
1403 Expr::List(list) => {
1404 for elt in &list.elts {
1405 self.collect_names_from_expr(elt, names);
1406 }
1407 }
1408 _ => {}
1409 }
1410 }
1411
1412 #[allow(clippy::too_many_arguments)]
1413 fn visit_stmt_for_names(
1414 &self,
1415 stmt: &Stmt,
1416 file_path: &PathBuf,
1417 content: &str,
1418 line_index: &[usize],
1419 declared_params: &std::collections::HashSet<String>,
1420 local_vars: &std::collections::HashMap<String, usize>,
1421 function_name: &str,
1422 function_line: usize,
1423 ) {
1424 match stmt {
1425 Stmt::Expr(expr_stmt) => {
1426 self.visit_expr_for_names(
1427 &expr_stmt.value,
1428 file_path,
1429 content,
1430 line_index,
1431 declared_params,
1432 local_vars,
1433 function_name,
1434 function_line,
1435 );
1436 }
1437 Stmt::Assign(assign) => {
1438 self.visit_expr_for_names(
1439 &assign.value,
1440 file_path,
1441 content,
1442 line_index,
1443 declared_params,
1444 local_vars,
1445 function_name,
1446 function_line,
1447 );
1448 }
1449 Stmt::AugAssign(aug_assign) => {
1450 self.visit_expr_for_names(
1451 &aug_assign.value,
1452 file_path,
1453 content,
1454 line_index,
1455 declared_params,
1456 local_vars,
1457 function_name,
1458 function_line,
1459 );
1460 }
1461 Stmt::Return(ret) => {
1462 if let Some(ref value) = ret.value {
1463 self.visit_expr_for_names(
1464 value,
1465 file_path,
1466 content,
1467 line_index,
1468 declared_params,
1469 local_vars,
1470 function_name,
1471 function_line,
1472 );
1473 }
1474 }
1475 Stmt::If(if_stmt) => {
1476 self.visit_expr_for_names(
1477 &if_stmt.test,
1478 file_path,
1479 content,
1480 line_index,
1481 declared_params,
1482 local_vars,
1483 function_name,
1484 function_line,
1485 );
1486 for stmt in &if_stmt.body {
1487 self.visit_stmt_for_names(
1488 stmt,
1489 file_path,
1490 content,
1491 line_index,
1492 declared_params,
1493 local_vars,
1494 function_name,
1495 function_line,
1496 );
1497 }
1498 for stmt in &if_stmt.orelse {
1499 self.visit_stmt_for_names(
1500 stmt,
1501 file_path,
1502 content,
1503 line_index,
1504 declared_params,
1505 local_vars,
1506 function_name,
1507 function_line,
1508 );
1509 }
1510 }
1511 Stmt::While(while_stmt) => {
1512 self.visit_expr_for_names(
1513 &while_stmt.test,
1514 file_path,
1515 content,
1516 line_index,
1517 declared_params,
1518 local_vars,
1519 function_name,
1520 function_line,
1521 );
1522 for stmt in &while_stmt.body {
1523 self.visit_stmt_for_names(
1524 stmt,
1525 file_path,
1526 content,
1527 line_index,
1528 declared_params,
1529 local_vars,
1530 function_name,
1531 function_line,
1532 );
1533 }
1534 }
1535 Stmt::For(for_stmt) => {
1536 self.visit_expr_for_names(
1537 &for_stmt.iter,
1538 file_path,
1539 content,
1540 line_index,
1541 declared_params,
1542 local_vars,
1543 function_name,
1544 function_line,
1545 );
1546 for stmt in &for_stmt.body {
1547 self.visit_stmt_for_names(
1548 stmt,
1549 file_path,
1550 content,
1551 line_index,
1552 declared_params,
1553 local_vars,
1554 function_name,
1555 function_line,
1556 );
1557 }
1558 }
1559 Stmt::With(with_stmt) => {
1560 for item in &with_stmt.items {
1561 self.visit_expr_for_names(
1562 &item.context_expr,
1563 file_path,
1564 content,
1565 line_index,
1566 declared_params,
1567 local_vars,
1568 function_name,
1569 function_line,
1570 );
1571 }
1572 for stmt in &with_stmt.body {
1573 self.visit_stmt_for_names(
1574 stmt,
1575 file_path,
1576 content,
1577 line_index,
1578 declared_params,
1579 local_vars,
1580 function_name,
1581 function_line,
1582 );
1583 }
1584 }
1585 Stmt::AsyncFor(for_stmt) => {
1586 self.visit_expr_for_names(
1587 &for_stmt.iter,
1588 file_path,
1589 content,
1590 line_index,
1591 declared_params,
1592 local_vars,
1593 function_name,
1594 function_line,
1595 );
1596 for stmt in &for_stmt.body {
1597 self.visit_stmt_for_names(
1598 stmt,
1599 file_path,
1600 content,
1601 line_index,
1602 declared_params,
1603 local_vars,
1604 function_name,
1605 function_line,
1606 );
1607 }
1608 }
1609 Stmt::AsyncWith(with_stmt) => {
1610 for item in &with_stmt.items {
1611 self.visit_expr_for_names(
1612 &item.context_expr,
1613 file_path,
1614 content,
1615 line_index,
1616 declared_params,
1617 local_vars,
1618 function_name,
1619 function_line,
1620 );
1621 }
1622 for stmt in &with_stmt.body {
1623 self.visit_stmt_for_names(
1624 stmt,
1625 file_path,
1626 content,
1627 line_index,
1628 declared_params,
1629 local_vars,
1630 function_name,
1631 function_line,
1632 );
1633 }
1634 }
1635 Stmt::Assert(assert_stmt) => {
1636 self.visit_expr_for_names(
1637 &assert_stmt.test,
1638 file_path,
1639 content,
1640 line_index,
1641 declared_params,
1642 local_vars,
1643 function_name,
1644 function_line,
1645 );
1646 if let Some(ref msg) = assert_stmt.msg {
1647 self.visit_expr_for_names(
1648 msg,
1649 file_path,
1650 content,
1651 line_index,
1652 declared_params,
1653 local_vars,
1654 function_name,
1655 function_line,
1656 );
1657 }
1658 }
1659 _ => {} }
1661 }
1662
1663 #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
1664 fn visit_expr_for_names(
1665 &self,
1666 expr: &Expr,
1667 file_path: &PathBuf,
1668 content: &str,
1669 line_index: &[usize],
1670 declared_params: &std::collections::HashSet<String>,
1671 local_vars: &std::collections::HashMap<String, usize>,
1672 function_name: &str,
1673 function_line: usize,
1674 ) {
1675 match expr {
1676 Expr::Name(name) => {
1677 let name_str = name.id.as_str();
1678 let line = self.get_line_from_offset(name.range.start().to_usize(), line_index);
1679
1680 let is_local_var_in_scope = local_vars
1684 .get(name_str)
1685 .map(|def_line| *def_line < line)
1686 .unwrap_or(false);
1687
1688 if !declared_params.contains(name_str)
1689 && !is_local_var_in_scope
1690 && self.is_available_fixture(file_path, name_str)
1691 {
1692 let start_char = self
1693 .get_char_position_from_offset(name.range.start().to_usize(), line_index);
1694 let end_char =
1695 self.get_char_position_from_offset(name.range.end().to_usize(), line_index);
1696
1697 info!(
1698 "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1699 name_str, file_path, line, start_char, function_name
1700 );
1701
1702 let undeclared = UndeclaredFixture {
1703 name: name_str.to_string(),
1704 file_path: file_path.clone(),
1705 line,
1706 start_char,
1707 end_char,
1708 function_name: function_name.to_string(),
1709 function_line,
1710 };
1711
1712 self.undeclared_fixtures
1713 .entry(file_path.clone())
1714 .or_default()
1715 .push(undeclared);
1716 }
1717 }
1718 Expr::Call(call) => {
1719 self.visit_expr_for_names(
1720 &call.func,
1721 file_path,
1722 content,
1723 line_index,
1724 declared_params,
1725 local_vars,
1726 function_name,
1727 function_line,
1728 );
1729 for arg in &call.args {
1730 self.visit_expr_for_names(
1731 arg,
1732 file_path,
1733 content,
1734 line_index,
1735 declared_params,
1736 local_vars,
1737 function_name,
1738 function_line,
1739 );
1740 }
1741 }
1742 Expr::Attribute(attr) => {
1743 self.visit_expr_for_names(
1744 &attr.value,
1745 file_path,
1746 content,
1747 line_index,
1748 declared_params,
1749 local_vars,
1750 function_name,
1751 function_line,
1752 );
1753 }
1754 Expr::BinOp(binop) => {
1755 self.visit_expr_for_names(
1756 &binop.left,
1757 file_path,
1758 content,
1759 line_index,
1760 declared_params,
1761 local_vars,
1762 function_name,
1763 function_line,
1764 );
1765 self.visit_expr_for_names(
1766 &binop.right,
1767 file_path,
1768 content,
1769 line_index,
1770 declared_params,
1771 local_vars,
1772 function_name,
1773 function_line,
1774 );
1775 }
1776 Expr::UnaryOp(unaryop) => {
1777 self.visit_expr_for_names(
1778 &unaryop.operand,
1779 file_path,
1780 content,
1781 line_index,
1782 declared_params,
1783 local_vars,
1784 function_name,
1785 function_line,
1786 );
1787 }
1788 Expr::Compare(compare) => {
1789 self.visit_expr_for_names(
1790 &compare.left,
1791 file_path,
1792 content,
1793 line_index,
1794 declared_params,
1795 local_vars,
1796 function_name,
1797 function_line,
1798 );
1799 for comparator in &compare.comparators {
1800 self.visit_expr_for_names(
1801 comparator,
1802 file_path,
1803 content,
1804 line_index,
1805 declared_params,
1806 local_vars,
1807 function_name,
1808 function_line,
1809 );
1810 }
1811 }
1812 Expr::Subscript(subscript) => {
1813 self.visit_expr_for_names(
1814 &subscript.value,
1815 file_path,
1816 content,
1817 line_index,
1818 declared_params,
1819 local_vars,
1820 function_name,
1821 function_line,
1822 );
1823 self.visit_expr_for_names(
1824 &subscript.slice,
1825 file_path,
1826 content,
1827 line_index,
1828 declared_params,
1829 local_vars,
1830 function_name,
1831 function_line,
1832 );
1833 }
1834 Expr::List(list) => {
1835 for elt in &list.elts {
1836 self.visit_expr_for_names(
1837 elt,
1838 file_path,
1839 content,
1840 line_index,
1841 declared_params,
1842 local_vars,
1843 function_name,
1844 function_line,
1845 );
1846 }
1847 }
1848 Expr::Tuple(tuple) => {
1849 for elt in &tuple.elts {
1850 self.visit_expr_for_names(
1851 elt,
1852 file_path,
1853 content,
1854 line_index,
1855 declared_params,
1856 local_vars,
1857 function_name,
1858 function_line,
1859 );
1860 }
1861 }
1862 Expr::Dict(dict) => {
1863 for k in dict.keys.iter().flatten() {
1864 self.visit_expr_for_names(
1865 k,
1866 file_path,
1867 content,
1868 line_index,
1869 declared_params,
1870 local_vars,
1871 function_name,
1872 function_line,
1873 );
1874 }
1875 for value in &dict.values {
1876 self.visit_expr_for_names(
1877 value,
1878 file_path,
1879 content,
1880 line_index,
1881 declared_params,
1882 local_vars,
1883 function_name,
1884 function_line,
1885 );
1886 }
1887 }
1888 Expr::Await(await_expr) => {
1889 self.visit_expr_for_names(
1891 &await_expr.value,
1892 file_path,
1893 content,
1894 line_index,
1895 declared_params,
1896 local_vars,
1897 function_name,
1898 function_line,
1899 );
1900 }
1901 _ => {} }
1903 }
1904
1905 fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1906 if let Some(definitions) = self.definitions.get(fixture_name) {
1908 for def in definitions.iter() {
1910 if def.file_path == file_path {
1912 return true;
1913 }
1914
1915 if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1917 && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1918 {
1919 return true;
1920 }
1921
1922 if def.file_path.to_string_lossy().contains("site-packages") {
1924 return true;
1925 }
1926 }
1927 }
1928 false
1929 }
1930
1931 fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1932 if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1934 if let Expr::Constant(constant) = &*expr_stmt.value {
1935 if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1937 return Some(self.format_docstring(s.to_string()));
1938 }
1939 }
1940 }
1941 None
1942 }
1943
1944 fn format_docstring(&self, docstring: String) -> String {
1945 let lines: Vec<&str> = docstring.lines().collect();
1948
1949 if lines.is_empty() {
1950 return String::new();
1951 }
1952
1953 let mut start = 0;
1955 let mut end = lines.len();
1956
1957 while start < lines.len() && lines[start].trim().is_empty() {
1958 start += 1;
1959 }
1960
1961 while end > start && lines[end - 1].trim().is_empty() {
1962 end -= 1;
1963 }
1964
1965 if start >= end {
1966 return String::new();
1967 }
1968
1969 let lines = &lines[start..end];
1970
1971 let mut min_indent = usize::MAX;
1973 for (i, line) in lines.iter().enumerate() {
1974 if i == 0 && !line.trim().is_empty() {
1975 continue;
1977 }
1978
1979 if !line.trim().is_empty() {
1980 let indent = line.len() - line.trim_start().len();
1981 min_indent = min_indent.min(indent);
1982 }
1983 }
1984
1985 if min_indent == usize::MAX {
1986 min_indent = 0;
1987 }
1988
1989 let mut result = Vec::new();
1991 for (i, line) in lines.iter().enumerate() {
1992 if i == 0 {
1993 result.push(line.trim().to_string());
1995 } else if line.trim().is_empty() {
1996 result.push(String::new());
1998 } else {
1999 let dedented = if line.len() > min_indent {
2001 &line[min_indent..]
2002 } else {
2003 line.trim_start()
2004 };
2005 result.push(dedented.to_string());
2006 }
2007 }
2008
2009 result.join("\n")
2011 }
2012
2013 fn extract_return_type(
2014 &self,
2015 returns: &Option<Box<rustpython_parser::ast::Expr>>,
2016 body: &[Stmt],
2017 content: &str,
2018 ) -> Option<String> {
2019 if let Some(return_expr) = returns {
2020 let has_yield = self.contains_yield(body);
2022
2023 if has_yield {
2024 return self.extract_yielded_type(return_expr, content);
2027 } else {
2028 return Some(self.expr_to_string(return_expr, content));
2030 }
2031 }
2032 None
2033 }
2034
2035 #[allow(clippy::only_used_in_recursion)]
2036 fn contains_yield(&self, body: &[Stmt]) -> bool {
2037 for stmt in body {
2038 match stmt {
2039 Stmt::Expr(expr_stmt) => {
2040 if let Expr::Yield(_) | Expr::YieldFrom(_) = &*expr_stmt.value {
2041 return true;
2042 }
2043 }
2044 Stmt::If(if_stmt) => {
2045 if self.contains_yield(&if_stmt.body) || self.contains_yield(&if_stmt.orelse) {
2046 return true;
2047 }
2048 }
2049 Stmt::For(for_stmt) => {
2050 if self.contains_yield(&for_stmt.body) || self.contains_yield(&for_stmt.orelse)
2051 {
2052 return true;
2053 }
2054 }
2055 Stmt::While(while_stmt) => {
2056 if self.contains_yield(&while_stmt.body)
2057 || self.contains_yield(&while_stmt.orelse)
2058 {
2059 return true;
2060 }
2061 }
2062 Stmt::With(with_stmt) => {
2063 if self.contains_yield(&with_stmt.body) {
2064 return true;
2065 }
2066 }
2067 Stmt::Try(try_stmt) => {
2068 if self.contains_yield(&try_stmt.body)
2069 || self.contains_yield(&try_stmt.orelse)
2070 || self.contains_yield(&try_stmt.finalbody)
2071 {
2072 return true;
2073 }
2074 }
2077 _ => {}
2078 }
2079 }
2080 false
2081 }
2082
2083 fn extract_yielded_type(
2084 &self,
2085 expr: &rustpython_parser::ast::Expr,
2086 content: &str,
2087 ) -> Option<String> {
2088 if let Expr::Subscript(subscript) = expr {
2092 let _base_name = self.expr_to_string(&subscript.value, content);
2094
2095 if let Expr::Tuple(tuple) = &*subscript.slice {
2097 if let Some(first_elem) = tuple.elts.first() {
2098 return Some(self.expr_to_string(first_elem, content));
2099 }
2100 } else {
2101 return Some(self.expr_to_string(&subscript.slice, content));
2103 }
2104 }
2105
2106 Some(self.expr_to_string(expr, content))
2108 }
2109
2110 #[allow(clippy::only_used_in_recursion)]
2111 fn expr_to_string(&self, expr: &rustpython_parser::ast::Expr, content: &str) -> String {
2112 match expr {
2113 Expr::Name(name) => name.id.to_string(),
2114 Expr::Attribute(attr) => {
2115 format!(
2116 "{}.{}",
2117 self.expr_to_string(&attr.value, content),
2118 attr.attr
2119 )
2120 }
2121 Expr::Subscript(subscript) => {
2122 let base = self.expr_to_string(&subscript.value, content);
2123 let slice = self.expr_to_string(&subscript.slice, content);
2124 format!("{}[{}]", base, slice)
2125 }
2126 Expr::Tuple(tuple) => {
2127 let elements: Vec<String> = tuple
2128 .elts
2129 .iter()
2130 .map(|e| self.expr_to_string(e, content))
2131 .collect();
2132 elements.join(", ")
2133 }
2134 Expr::Constant(constant) => {
2135 format!("{:?}", constant.value)
2136 }
2137 Expr::BinOp(binop) if matches!(binop.op, rustpython_parser::ast::Operator::BitOr) => {
2138 format!(
2140 "{} | {}",
2141 self.expr_to_string(&binop.left, content),
2142 self.expr_to_string(&binop.right, content)
2143 )
2144 }
2145 _ => {
2146 "Any".to_string()
2148 }
2149 }
2150 }
2151
2152 fn build_line_index(content: &str) -> Vec<usize> {
2153 let mut line_index = Vec::with_capacity(content.len() / 30);
2154 line_index.push(0);
2155 for (i, c) in content.char_indices() {
2156 if c == '\n' {
2157 line_index.push(i + 1);
2158 }
2159 }
2160 line_index
2161 }
2162
2163 fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
2164 match line_index.binary_search(&offset) {
2165 Ok(line) => line + 1,
2166 Err(line) => line,
2167 }
2168 }
2169
2170 fn get_char_position_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
2171 let line = self.get_line_from_offset(offset, line_index);
2172 let line_start = line_index[line - 1];
2173 offset.saturating_sub(line_start)
2174 }
2175
2176 pub fn find_fixture_definition(
2178 &self,
2179 file_path: &Path,
2180 line: u32,
2181 character: u32,
2182 ) -> Option<FixtureDefinition> {
2183 debug!(
2184 "find_fixture_definition: file={:?}, line={}, char={}",
2185 file_path, line, character
2186 );
2187
2188 let target_line = (line + 1) as usize; let content = self.get_file_content(file_path)?;
2193
2194 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
2196 debug!("Line content: {}", line_content);
2197
2198 let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
2200 debug!("Word at cursor: {:?}", word_at_cursor);
2201
2202 let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
2205
2206 if let Some(usages) = self.usages.get(file_path) {
2209 for usage in usages.iter() {
2210 if usage.line == target_line && usage.name == word_at_cursor {
2211 let cursor_pos = character as usize;
2213 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
2214 debug!(
2215 "Cursor at {} is within usage range {}-{}: {}",
2216 cursor_pos, usage.start_char, usage.end_char, usage.name
2217 );
2218 info!("Found fixture usage at cursor position: {}", usage.name);
2219
2220 if let Some(ref current_def) = current_fixture_def {
2222 if current_def.name == word_at_cursor {
2223 info!(
2224 "Self-referencing fixture detected, finding parent definition"
2225 );
2226 return self.find_closest_definition_excluding(
2227 file_path,
2228 &usage.name,
2229 Some(current_def),
2230 );
2231 }
2232 }
2233
2234 return self.find_closest_definition(file_path, &usage.name);
2236 }
2237 }
2238 }
2239 }
2240
2241 debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
2242 None
2243 }
2244
2245 fn get_fixture_definition_at_line(
2247 &self,
2248 file_path: &Path,
2249 line: usize,
2250 ) -> Option<FixtureDefinition> {
2251 for entry in self.definitions.iter() {
2252 for def in entry.value().iter() {
2253 if def.file_path == file_path && def.line == line {
2254 return Some(def.clone());
2255 }
2256 }
2257 }
2258 None
2259 }
2260
2261 pub fn get_definition_at_line(
2264 &self,
2265 file_path: &Path,
2266 line: usize,
2267 fixture_name: &str,
2268 ) -> Option<FixtureDefinition> {
2269 if let Some(definitions) = self.definitions.get(fixture_name) {
2270 for def in definitions.iter() {
2271 if def.file_path == file_path && def.line == line {
2272 return Some(def.clone());
2273 }
2274 }
2275 }
2276 None
2277 }
2278
2279 fn find_closest_definition(
2280 &self,
2281 file_path: &Path,
2282 fixture_name: &str,
2283 ) -> Option<FixtureDefinition> {
2284 let definitions = self.definitions.get(fixture_name)?;
2285
2286 debug!(
2289 "Checking for fixture {} in same file: {:?}",
2290 fixture_name, file_path
2291 );
2292
2293 if let Some(last_def) = definitions
2295 .iter()
2296 .filter(|def| def.file_path == file_path)
2297 .max_by_key(|def| def.line)
2298 {
2299 info!(
2300 "Found fixture {} in same file at line {} (using last definition)",
2301 fixture_name, last_def.line
2302 );
2303 return Some(last_def.clone());
2304 }
2305
2306 let mut current_dir = file_path.parent()?;
2309
2310 debug!(
2311 "Searching for fixture {} in conftest.py files starting from {:?}",
2312 fixture_name, current_dir
2313 );
2314 loop {
2315 let conftest_path = current_dir.join("conftest.py");
2317 debug!(" Checking conftest.py at: {:?}", conftest_path);
2318
2319 for def in definitions.iter() {
2320 if def.file_path == conftest_path {
2321 info!(
2322 "Found fixture {} in conftest.py: {:?}",
2323 fixture_name, conftest_path
2324 );
2325 return Some(def.clone());
2326 }
2327 }
2328
2329 match current_dir.parent() {
2331 Some(parent) => current_dir = parent,
2332 None => break,
2333 }
2334 }
2335
2336 debug!(
2339 "No fixture {} found in conftest hierarchy, checking for third-party fixtures",
2340 fixture_name
2341 );
2342 for def in definitions.iter() {
2343 if def.file_path.to_string_lossy().contains("site-packages") {
2344 info!(
2345 "Found third-party fixture {} in site-packages: {:?}",
2346 fixture_name, def.file_path
2347 );
2348 return Some(def.clone());
2349 }
2350 }
2351
2352 warn!(
2357 "No fixture {} found following priority rules (same file, conftest hierarchy, third-party)",
2358 fixture_name
2359 );
2360 warn!(
2361 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
2362 );
2363
2364 let mut defs: Vec<_> = definitions.iter().cloned().collect();
2365 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
2366 defs.first().cloned()
2367 }
2368
2369 fn find_closest_definition_excluding(
2372 &self,
2373 file_path: &Path,
2374 fixture_name: &str,
2375 exclude: Option<&FixtureDefinition>,
2376 ) -> Option<FixtureDefinition> {
2377 let definitions = self.definitions.get(fixture_name)?;
2378
2379 debug!(
2383 "Checking for fixture {} in same file: {:?} (excluding: {:?})",
2384 fixture_name, file_path, exclude
2385 );
2386
2387 if let Some(last_def) = definitions
2389 .iter()
2390 .filter(|def| {
2391 if def.file_path != file_path {
2392 return false;
2393 }
2394 if let Some(excluded) = exclude {
2396 if def == &excluded {
2397 debug!("Skipping excluded definition at line {}", def.line);
2398 return false;
2399 }
2400 }
2401 true
2402 })
2403 .max_by_key(|def| def.line)
2404 {
2405 info!(
2406 "Found fixture {} in same file at line {} (using last definition, excluding specified)",
2407 fixture_name, last_def.line
2408 );
2409 return Some(last_def.clone());
2410 }
2411
2412 let mut current_dir = file_path.parent()?;
2414
2415 debug!(
2416 "Searching for fixture {} in conftest.py files starting from {:?}",
2417 fixture_name, current_dir
2418 );
2419 loop {
2420 let conftest_path = current_dir.join("conftest.py");
2421 debug!(" Checking conftest.py at: {:?}", conftest_path);
2422
2423 for def in definitions.iter() {
2424 if def.file_path == conftest_path {
2425 if let Some(excluded) = exclude {
2427 if def == excluded {
2428 debug!("Skipping excluded definition at line {}", def.line);
2429 continue;
2430 }
2431 }
2432 info!(
2433 "Found fixture {} in conftest.py: {:?}",
2434 fixture_name, conftest_path
2435 );
2436 return Some(def.clone());
2437 }
2438 }
2439
2440 match current_dir.parent() {
2442 Some(parent) => current_dir = parent,
2443 None => break,
2444 }
2445 }
2446
2447 debug!(
2449 "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
2450 fixture_name
2451 );
2452 for def in definitions.iter() {
2453 if let Some(excluded) = exclude {
2455 if def == excluded {
2456 continue;
2457 }
2458 }
2459 if def.file_path.to_string_lossy().contains("site-packages") {
2460 info!(
2461 "Found third-party fixture {} in site-packages: {:?}",
2462 fixture_name, def.file_path
2463 );
2464 return Some(def.clone());
2465 }
2466 }
2467
2468 warn!(
2470 "No fixture {} found following priority rules (excluding specified)",
2471 fixture_name
2472 );
2473 warn!(
2474 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
2475 );
2476
2477 let mut defs: Vec<_> = definitions
2478 .iter()
2479 .filter(|def| {
2480 if let Some(excluded) = exclude {
2481 def != &excluded
2482 } else {
2483 true
2484 }
2485 })
2486 .cloned()
2487 .collect();
2488 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
2489 defs.first().cloned()
2490 }
2491
2492 pub fn find_fixture_at_position(
2494 &self,
2495 file_path: &Path,
2496 line: u32,
2497 character: u32,
2498 ) -> Option<String> {
2499 let target_line = (line + 1) as usize; debug!(
2502 "find_fixture_at_position: file={:?}, line={}, char={}",
2503 file_path, target_line, character
2504 );
2505
2506 let content = self.get_file_content(file_path)?;
2509
2510 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
2512 debug!("Line content: {}", line_content);
2513
2514 let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
2516 debug!("Word at cursor: {:?}", word_at_cursor);
2517
2518 if let Some(usages) = self.usages.get(file_path) {
2521 for usage in usages.iter() {
2522 if usage.line == target_line {
2523 let cursor_pos = character as usize;
2525 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
2526 debug!(
2527 "Cursor at {} is within usage range {}-{}: {}",
2528 cursor_pos, usage.start_char, usage.end_char, usage.name
2529 );
2530 info!("Found fixture usage at cursor position: {}", usage.name);
2531 return Some(usage.name.clone());
2532 }
2533 }
2534 }
2535 }
2536
2537 for entry in self.definitions.iter() {
2540 for def in entry.value().iter() {
2541 if def.file_path == file_path && def.line == target_line {
2542 if let Some(ref word) = word_at_cursor {
2544 if word == &def.name {
2545 info!(
2546 "Found fixture definition name at cursor position: {}",
2547 def.name
2548 );
2549 return Some(def.name.clone());
2550 }
2551 }
2552 }
2555 }
2556 }
2557
2558 debug!("No fixture found at cursor position");
2559 None
2560 }
2561
2562 pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
2563 let char_indices: Vec<(usize, char)> = line.char_indices().collect();
2565
2566 if character >= char_indices.len() {
2568 return None;
2569 }
2570
2571 let (_byte_pos, c) = char_indices[character];
2573
2574 if c.is_alphanumeric() || c == '_' {
2576 let mut start_idx = character;
2578 while start_idx > 0 {
2579 let (_, prev_c) = char_indices[start_idx - 1];
2580 if !prev_c.is_alphanumeric() && prev_c != '_' {
2581 break;
2582 }
2583 start_idx -= 1;
2584 }
2585
2586 let mut end_idx = character + 1;
2588 while end_idx < char_indices.len() {
2589 let (_, curr_c) = char_indices[end_idx];
2590 if !curr_c.is_alphanumeric() && curr_c != '_' {
2591 break;
2592 }
2593 end_idx += 1;
2594 }
2595
2596 let start_byte = char_indices[start_idx].0;
2598 let end_byte = if end_idx < char_indices.len() {
2599 char_indices[end_idx].0
2600 } else {
2601 line.len()
2602 };
2603
2604 return Some(line[start_byte..end_byte].to_string());
2605 }
2606
2607 None
2608 }
2609
2610 pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
2612 info!("Finding all references for fixture: {}", fixture_name);
2613
2614 let mut all_references = Vec::new();
2615
2616 for entry in self.usages.iter() {
2618 let file_path = entry.key();
2619 let usages = entry.value();
2620
2621 for usage in usages.iter() {
2623 if usage.name == fixture_name {
2624 debug!(
2625 "Found reference to {} in {:?} at line {}",
2626 fixture_name, file_path, usage.line
2627 );
2628 all_references.push(usage.clone());
2629 }
2630 }
2631 }
2632
2633 info!(
2634 "Found {} total references for fixture: {}",
2635 all_references.len(),
2636 fixture_name
2637 );
2638 all_references
2639 }
2640
2641 pub fn find_references_for_definition(
2648 &self,
2649 definition: &FixtureDefinition,
2650 ) -> Vec<FixtureUsage> {
2651 info!(
2652 "Finding references for specific definition: {} at {:?}:{}",
2653 definition.name, definition.file_path, definition.line
2654 );
2655
2656 let mut matching_references = Vec::new();
2657
2658 for entry in self.usages.iter() {
2660 let file_path = entry.key();
2661 let usages = entry.value();
2662
2663 for usage in usages.iter() {
2664 if usage.name == definition.name {
2665 let fixture_def_at_line =
2668 self.get_fixture_definition_at_line(file_path, usage.line);
2669
2670 let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
2671 if current_def.name == usage.name {
2672 debug!(
2674 "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
2675 file_path, usage.line, current_def.line
2676 );
2677 self.find_closest_definition_excluding(
2678 file_path,
2679 &usage.name,
2680 Some(current_def),
2681 )
2682 } else {
2683 self.find_closest_definition(file_path, &usage.name)
2685 }
2686 } else {
2687 self.find_closest_definition(file_path, &usage.name)
2689 };
2690
2691 if let Some(resolved_def) = resolved_def {
2692 if resolved_def == *definition {
2693 debug!(
2694 "Usage at {:?}:{} resolves to our definition",
2695 file_path, usage.line
2696 );
2697 matching_references.push(usage.clone());
2698 } else {
2699 debug!(
2700 "Usage at {:?}:{} resolves to different definition at {:?}:{}",
2701 file_path, usage.line, resolved_def.file_path, resolved_def.line
2702 );
2703 }
2704 }
2705 }
2706 }
2707 }
2708
2709 info!(
2710 "Found {} references that resolve to this specific definition",
2711 matching_references.len()
2712 );
2713 matching_references
2714 }
2715
2716 pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
2718 self.undeclared_fixtures
2719 .get(file_path)
2720 .map(|entry| entry.value().clone())
2721 .unwrap_or_default()
2722 }
2723
2724 pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
2727 let mut available_fixtures = Vec::new();
2728 let mut seen_names = std::collections::HashSet::new();
2729
2730 for entry in self.definitions.iter() {
2732 let fixture_name = entry.key();
2733 for def in entry.value().iter() {
2734 if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
2735 available_fixtures.push(def.clone());
2736 seen_names.insert(fixture_name.clone());
2737 }
2738 }
2739 }
2740
2741 if let Some(mut current_dir) = file_path.parent() {
2743 loop {
2744 let conftest_path = current_dir.join("conftest.py");
2745
2746 for entry in self.definitions.iter() {
2747 let fixture_name = entry.key();
2748 for def in entry.value().iter() {
2749 if def.file_path == conftest_path
2750 && !seen_names.contains(fixture_name.as_str())
2751 {
2752 available_fixtures.push(def.clone());
2753 seen_names.insert(fixture_name.clone());
2754 }
2755 }
2756 }
2757
2758 match current_dir.parent() {
2760 Some(parent) => current_dir = parent,
2761 None => break,
2762 }
2763 }
2764 }
2765
2766 for entry in self.definitions.iter() {
2768 let fixture_name = entry.key();
2769 for def in entry.value().iter() {
2770 if def.file_path.to_string_lossy().contains("site-packages")
2771 && !seen_names.contains(fixture_name.as_str())
2772 {
2773 available_fixtures.push(def.clone());
2774 seen_names.insert(fixture_name.clone());
2775 }
2776 }
2777 }
2778
2779 available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
2781 available_fixtures
2782 }
2783
2784 pub fn is_inside_function(
2787 &self,
2788 file_path: &Path,
2789 line: u32,
2790 character: u32,
2791 ) -> Option<(String, bool, Vec<String>)> {
2792 let content = self.get_file_content(file_path)?;
2794
2795 let target_line = (line + 1) as usize; let parsed = parse(&content, Mode::Module, "").ok()?;
2799
2800 if let rustpython_parser::ast::Mod::Module(module) = parsed {
2801 return self.find_enclosing_function(
2802 &module.body,
2803 &content,
2804 target_line,
2805 character as usize,
2806 );
2807 }
2808
2809 None
2810 }
2811
2812 fn find_enclosing_function(
2813 &self,
2814 stmts: &[Stmt],
2815 content: &str,
2816 target_line: usize,
2817 _target_char: usize,
2818 ) -> Option<(String, bool, Vec<String>)> {
2819 for stmt in stmts {
2820 match stmt {
2821 Stmt::FunctionDef(func_def) => {
2822 let func_start_line = content[..func_def.range.start().to_usize()]
2823 .matches('\n')
2824 .count()
2825 + 1;
2826 let func_end_line = content[..func_def.range.end().to_usize()]
2827 .matches('\n')
2828 .count()
2829 + 1;
2830
2831 if target_line >= func_start_line && target_line <= func_end_line {
2833 let is_fixture = func_def
2834 .decorator_list
2835 .iter()
2836 .any(Self::is_fixture_decorator);
2837 let is_test = func_def.name.starts_with("test_");
2838
2839 if is_test || is_fixture {
2841 let params: Vec<String> = func_def
2842 .args
2843 .args
2844 .iter()
2845 .map(|arg| arg.def.arg.to_string())
2846 .collect();
2847
2848 return Some((func_def.name.to_string(), is_fixture, params));
2849 }
2850 }
2851 }
2852 Stmt::AsyncFunctionDef(func_def) => {
2853 let func_start_line = content[..func_def.range.start().to_usize()]
2854 .matches('\n')
2855 .count()
2856 + 1;
2857 let func_end_line = content[..func_def.range.end().to_usize()]
2858 .matches('\n')
2859 .count()
2860 + 1;
2861
2862 if target_line >= func_start_line && target_line <= func_end_line {
2863 let is_fixture = func_def
2864 .decorator_list
2865 .iter()
2866 .any(Self::is_fixture_decorator);
2867 let is_test = func_def.name.starts_with("test_");
2868
2869 if is_test || is_fixture {
2870 let params: Vec<String> = func_def
2871 .args
2872 .args
2873 .iter()
2874 .map(|arg| arg.def.arg.to_string())
2875 .collect();
2876
2877 return Some((func_def.name.to_string(), is_fixture, params));
2878 }
2879 }
2880 }
2881 _ => {}
2882 }
2883 }
2884
2885 None
2886 }
2887
2888 pub fn print_fixtures_tree(&self, root_path: &Path, skip_unused: bool, only_unused: bool) {
2891 let mut file_fixtures: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
2893
2894 for entry in self.definitions.iter() {
2895 let fixture_name = entry.key();
2896 let definitions = entry.value();
2897
2898 for def in definitions {
2899 file_fixtures
2900 .entry(def.file_path.clone())
2901 .or_default()
2902 .insert(fixture_name.clone());
2903 }
2904 }
2905
2906 let mut fixture_usage_counts: HashMap<String, usize> = HashMap::new();
2908 for entry in self.usages.iter() {
2909 let usages = entry.value();
2910 for usage in usages {
2911 *fixture_usage_counts.entry(usage.name.clone()).or_insert(0) += 1;
2912 }
2913 }
2914
2915 let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
2917 let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
2918
2919 for file_path in file_fixtures.keys() {
2920 all_paths.insert(file_path.clone());
2921
2922 let mut current = file_path.as_path();
2924 while let Some(parent) = current.parent() {
2925 if parent == root_path || parent.as_os_str().is_empty() {
2926 break;
2927 }
2928 all_paths.insert(parent.to_path_buf());
2929 current = parent;
2930 }
2931 }
2932
2933 for path in &all_paths {
2935 if let Some(parent) = path.parent() {
2936 if parent != root_path && !parent.as_os_str().is_empty() {
2937 tree.entry(parent.to_path_buf())
2938 .or_default()
2939 .push(path.clone());
2940 }
2941 }
2942 }
2943
2944 for children in tree.values_mut() {
2946 children.sort();
2947 }
2948
2949 println!("Fixtures tree for: {}", root_path.display());
2951 println!();
2952
2953 if file_fixtures.is_empty() {
2954 println!("No fixtures found in this directory.");
2955 return;
2956 }
2957
2958 let mut top_level: Vec<PathBuf> = all_paths
2960 .iter()
2961 .filter(|p| {
2962 if let Some(parent) = p.parent() {
2963 parent == root_path
2964 } else {
2965 false
2966 }
2967 })
2968 .cloned()
2969 .collect();
2970 top_level.sort();
2971
2972 for (i, path) in top_level.iter().enumerate() {
2973 let is_last = i == top_level.len() - 1;
2974 self.print_tree_node(
2975 path,
2976 &file_fixtures,
2977 &tree,
2978 "",
2979 is_last,
2980 true, &fixture_usage_counts,
2982 skip_unused,
2983 only_unused,
2984 );
2985 }
2986 }
2987
2988 #[allow(clippy::too_many_arguments)]
2989 #[allow(clippy::only_used_in_recursion)]
2990 fn print_tree_node(
2991 &self,
2992 path: &Path,
2993 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2994 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2995 prefix: &str,
2996 is_last: bool,
2997 is_root_level: bool,
2998 fixture_usage_counts: &HashMap<String, usize>,
2999 skip_unused: bool,
3000 only_unused: bool,
3001 ) {
3002 use colored::Colorize;
3003 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
3004
3005 let connector = if is_root_level {
3007 "" } else if is_last {
3009 "└── "
3010 } else {
3011 "├── "
3012 };
3013
3014 if path.is_file() {
3015 if let Some(fixtures) = file_fixtures.get(path) {
3017 let fixture_vec: Vec<_> = fixtures
3019 .iter()
3020 .filter(|fixture_name| {
3021 let usage_count = fixture_usage_counts
3022 .get(*fixture_name)
3023 .copied()
3024 .unwrap_or(0);
3025 if only_unused {
3026 usage_count == 0
3027 } else if skip_unused {
3028 usage_count > 0
3029 } else {
3030 true
3031 }
3032 })
3033 .collect();
3034
3035 if fixture_vec.is_empty() {
3037 return;
3038 }
3039
3040 let file_display = name.to_string().cyan().bold();
3041 println!(
3042 "{}{}{} ({} fixtures)",
3043 prefix,
3044 connector,
3045 file_display,
3046 fixture_vec.len()
3047 );
3048
3049 let new_prefix = if is_root_level {
3051 "".to_string()
3052 } else {
3053 format!("{}{}", prefix, if is_last { " " } else { "│ " })
3054 };
3055
3056 for (j, fixture_name) in fixture_vec.iter().enumerate() {
3057 let is_last_fixture = j == fixture_vec.len() - 1;
3058 let fixture_connector = if is_last_fixture {
3059 "└── "
3060 } else {
3061 "├── "
3062 };
3063
3064 let usage_count = fixture_usage_counts
3066 .get(*fixture_name)
3067 .copied()
3068 .unwrap_or(0);
3069
3070 let fixture_display = if usage_count == 0 {
3072 fixture_name.to_string().dimmed()
3074 } else {
3075 fixture_name.to_string().green()
3077 };
3078
3079 let usage_info = if usage_count == 0 {
3081 "unused".dimmed().to_string()
3082 } else if usage_count == 1 {
3083 format!("{}", "used 1 time".yellow())
3084 } else {
3085 format!("{}", format!("used {} times", usage_count).yellow())
3086 };
3087
3088 println!(
3089 "{}{}{} ({})",
3090 new_prefix, fixture_connector, fixture_display, usage_info
3091 );
3092 }
3093 } else {
3094 println!("{}{}{}", prefix, connector, name);
3095 }
3096 } else {
3097 if let Some(children) = tree.get(path) {
3099 let has_visible_children = children.iter().any(|child| {
3101 Self::has_visible_fixtures(
3102 child,
3103 file_fixtures,
3104 tree,
3105 fixture_usage_counts,
3106 skip_unused,
3107 only_unused,
3108 )
3109 });
3110
3111 if !has_visible_children {
3112 return;
3113 }
3114
3115 let dir_display = format!("{}/", name).blue().bold();
3116 println!("{}{}{}", prefix, connector, dir_display);
3117
3118 let new_prefix = if is_root_level {
3119 "".to_string()
3120 } else {
3121 format!("{}{}", prefix, if is_last { " " } else { "│ " })
3122 };
3123
3124 for (j, child) in children.iter().enumerate() {
3125 let is_last_child = j == children.len() - 1;
3126 self.print_tree_node(
3127 child,
3128 file_fixtures,
3129 tree,
3130 &new_prefix,
3131 is_last_child,
3132 false, fixture_usage_counts,
3134 skip_unused,
3135 only_unused,
3136 );
3137 }
3138 }
3139 }
3140 }
3141
3142 fn has_visible_fixtures(
3143 path: &Path,
3144 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
3145 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
3146 fixture_usage_counts: &HashMap<String, usize>,
3147 skip_unused: bool,
3148 only_unused: bool,
3149 ) -> bool {
3150 if path.is_file() {
3151 if let Some(fixtures) = file_fixtures.get(path) {
3153 return fixtures.iter().any(|fixture_name| {
3154 let usage_count = fixture_usage_counts.get(fixture_name).copied().unwrap_or(0);
3155 if only_unused {
3156 usage_count == 0
3157 } else if skip_unused {
3158 usage_count > 0
3159 } else {
3160 true
3161 }
3162 });
3163 }
3164 false
3165 } else {
3166 if let Some(children) = tree.get(path) {
3168 children.iter().any(|child| {
3169 Self::has_visible_fixtures(
3170 child,
3171 file_fixtures,
3172 tree,
3173 fixture_usage_counts,
3174 skip_unused,
3175 only_unused,
3176 )
3177 })
3178 } else {
3179 false
3180 }
3181 }
3182 }
3183}