1use dashmap::DashMap;
2use rustpython_parser::ast::{Expr, Stmt};
3use rustpython_parser::{parse, Mode};
4use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tracing::{debug, 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 pub fn scan_workspace(&self, root_path: &Path) {
104 info!("Scanning workspace: {:?}", root_path);
105 let mut file_count = 0;
106 let mut error_count = 0;
107
108 for entry in WalkDir::new(root_path).into_iter() {
109 let entry = match entry {
110 Ok(e) => e,
111 Err(err) => {
112 if err
114 .io_error()
115 .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
116 {
117 warn!(
118 "Permission denied accessing path during workspace scan: {}",
119 err
120 );
121 } else {
122 error!("Error during workspace scan: {}", err);
123 error_count += 1;
124 }
125 continue;
126 }
127 };
128
129 let path = entry.path();
130
131 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
133 if filename == "conftest.py"
134 || filename.starts_with("test_") && filename.ends_with(".py")
135 || filename.ends_with("_test.py")
136 {
137 debug!("Found test/conftest file: {:?}", path);
138 match std::fs::read_to_string(path) {
139 Ok(content) => {
140 self.analyze_file(path.to_path_buf(), &content);
141 file_count += 1;
142 }
143 Err(err) => {
144 if err.kind() == std::io::ErrorKind::PermissionDenied {
145 warn!("Permission denied reading file: {:?}", path);
146 } else {
147 error!("Failed to read file {:?}: {}", path, err);
148 error_count += 1;
149 }
150 }
151 }
152 }
153 }
154 }
155
156 if error_count > 0 {
157 error!("Workspace scan completed with {} errors", error_count);
158 }
159
160 info!("Workspace scan complete. Processed {} files", file_count);
161
162 self.scan_venv_fixtures(root_path);
164
165 info!("Total fixtures defined: {}", self.definitions.len());
166 info!("Total files with fixture usages: {}", self.usages.len());
167 }
168
169 fn scan_venv_fixtures(&self, root_path: &Path) {
171 info!("Scanning for pytest plugins in virtual environment");
172
173 let venv_paths = vec![
175 root_path.join(".venv"),
176 root_path.join("venv"),
177 root_path.join("env"),
178 ];
179
180 info!("Checking for venv in: {:?}", root_path);
181 for venv_path in &venv_paths {
182 debug!("Checking venv path: {:?}", venv_path);
183 if venv_path.exists() {
184 info!("Found virtual environment at: {:?}", venv_path);
185 self.scan_venv_site_packages(venv_path);
186 return;
187 } else {
188 debug!(" Does not exist: {:?}", venv_path);
189 }
190 }
191
192 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
194 info!("Found VIRTUAL_ENV environment variable: {}", venv);
195 let venv_path = PathBuf::from(venv);
196 if venv_path.exists() {
197 info!("Using VIRTUAL_ENV: {:?}", venv_path);
198 self.scan_venv_site_packages(&venv_path);
199 return;
200 } else {
201 warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
202 }
203 } else {
204 debug!("No VIRTUAL_ENV environment variable set");
205 }
206
207 warn!("No virtual environment found - third-party fixtures will not be available");
208 }
209
210 fn scan_venv_site_packages(&self, venv_path: &Path) {
211 info!("Scanning venv site-packages in: {:?}", venv_path);
212
213 let lib_path = venv_path.join("lib");
215 debug!("Checking lib path: {:?}", lib_path);
216
217 if lib_path.exists() {
218 if let Ok(entries) = std::fs::read_dir(&lib_path) {
220 for entry in entries.flatten() {
221 let path = entry.path();
222 let dirname = path.file_name().unwrap_or_default().to_string_lossy();
223 debug!("Found in lib: {:?}", dirname);
224
225 if path.is_dir() && dirname.starts_with("python") {
226 let site_packages = path.join("site-packages");
227 debug!("Checking site-packages: {:?}", site_packages);
228
229 if site_packages.exists() {
230 info!("Found site-packages: {:?}", site_packages);
231 self.scan_pytest_plugins(&site_packages);
232 return;
233 }
234 }
235 }
236 }
237 }
238
239 let windows_site_packages = venv_path.join("Lib/site-packages");
241 debug!("Checking Windows path: {:?}", windows_site_packages);
242 if windows_site_packages.exists() {
243 info!("Found site-packages (Windows): {:?}", windows_site_packages);
244 self.scan_pytest_plugins(&windows_site_packages);
245 return;
246 }
247
248 warn!("Could not find site-packages in venv: {:?}", venv_path);
249 }
250
251 fn scan_pytest_plugins(&self, site_packages: &Path) {
252 info!("Scanning pytest plugins in: {:?}", site_packages);
253
254 let pytest_packages = vec![
256 "pytest_mock",
258 "pytest-mock",
259 "pytest_asyncio",
260 "pytest-asyncio",
261 "pytest_django",
262 "pytest-django",
263 "pytest_cov",
264 "pytest-cov",
265 "pytest_xdist",
266 "pytest-xdist",
267 "pytest_fixtures",
268 "pytest_flask",
270 "pytest-flask",
271 "pytest_httpx",
272 "pytest-httpx",
273 "pytest_postgresql",
274 "pytest-postgresql",
275 "pytest_mongodb",
276 "pytest-mongodb",
277 "pytest_redis",
278 "pytest-redis",
279 "pytest_elasticsearch",
280 "pytest-elasticsearch",
281 "pytest_rabbitmq",
282 "pytest-rabbitmq",
283 "pytest_mysql",
284 "pytest-mysql",
285 "pytest_docker",
286 "pytest-docker",
287 "pytest_kubernetes",
288 "pytest-kubernetes",
289 "pytest_celery",
290 "pytest-celery",
291 "pytest_tornado",
292 "pytest-tornado",
293 "pytest_aiohttp",
294 "pytest-aiohttp",
295 "pytest_sanic",
296 "pytest-sanic",
297 "pytest_fastapi",
298 "pytest-fastapi",
299 "pytest_alembic",
300 "pytest-alembic",
301 "pytest_sqlalchemy",
302 "pytest-sqlalchemy",
303 "pytest_factoryboy",
304 "pytest-factoryboy",
305 "pytest_freezegun",
306 "pytest-freezegun",
307 "pytest_mimesis",
308 "pytest-mimesis",
309 "pytest_lazy_fixture",
310 "pytest-lazy-fixture",
311 "pytest_cases",
312 "pytest-cases",
313 "pytest_bdd",
314 "pytest-bdd",
315 "pytest_benchmark",
316 "pytest-benchmark",
317 "pytest_timeout",
318 "pytest-timeout",
319 "pytest_retry",
320 "pytest-retry",
321 "pytest_repeat",
322 "pytest-repeat",
323 "pytest_rerunfailures",
324 "pytest-rerunfailures",
325 "pytest_ordering",
326 "pytest-ordering",
327 "pytest_dependency",
328 "pytest-dependency",
329 "pytest_random_order",
330 "pytest-random-order",
331 "pytest_picked",
332 "pytest-picked",
333 "pytest_testmon",
334 "pytest-testmon",
335 "pytest_split",
336 "pytest-split",
337 "pytest_env",
338 "pytest-env",
339 "pytest_dotenv",
340 "pytest-dotenv",
341 "pytest_html",
342 "pytest-html",
343 "pytest_json_report",
344 "pytest-json-report",
345 "pytest_metadata",
346 "pytest-metadata",
347 "pytest_instafail",
348 "pytest-instafail",
349 "pytest_clarity",
350 "pytest-clarity",
351 "pytest_sugar",
352 "pytest-sugar",
353 "pytest_emoji",
354 "pytest-emoji",
355 "pytest_play",
356 "pytest-play",
357 "pytest_selenium",
358 "pytest-selenium",
359 "pytest_playwright",
360 "pytest-playwright",
361 "pytest_splinter",
362 "pytest-splinter",
363 ];
364
365 let mut plugin_count = 0;
366
367 for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
368 let entry = match entry {
369 Ok(e) => e,
370 Err(_) => continue,
371 };
372
373 let path = entry.path();
374 let filename = path.file_name().unwrap_or_default().to_string_lossy();
375
376 let is_pytest_package = pytest_packages.iter().any(|pkg| filename.contains(pkg))
378 || filename.starts_with("pytest")
379 || filename.contains("_pytest");
380
381 if is_pytest_package && path.is_dir() {
382 if filename.ends_with(".dist-info") || filename.ends_with(".egg-info") {
384 debug!("Skipping dist-info directory: {:?}", filename);
385 continue;
386 }
387
388 info!("Scanning pytest plugin: {:?}", path);
389 plugin_count += 1;
390 self.scan_plugin_directory(&path);
391 } else {
392 if filename.contains("mock") {
394 debug!("Found mock-related package (not scanning): {:?}", filename);
395 }
396 }
397 }
398
399 info!("Scanned {} pytest plugin packages", plugin_count);
400 }
401
402 fn scan_plugin_directory(&self, plugin_dir: &Path) {
403 for entry in WalkDir::new(plugin_dir)
405 .max_depth(3) .into_iter()
407 .filter_map(|e| e.ok())
408 {
409 let path = entry.path();
410
411 if path.extension().and_then(|s| s.to_str()) == Some("py") {
412 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
414 if filename.starts_with("test_") || filename.contains("__pycache__") {
416 continue;
417 }
418
419 debug!("Scanning plugin file: {:?}", path);
420 if let Ok(content) = std::fs::read_to_string(path) {
421 self.analyze_file(path.to_path_buf(), &content);
422 }
423 }
424 }
425 }
426 }
427
428 pub fn analyze_file(&self, file_path: PathBuf, content: &str) {
430 let file_path = self.get_canonical_path(file_path);
432
433 debug!("Analyzing file: {:?}", file_path);
434
435 self.file_cache
438 .insert(file_path.clone(), Arc::new(content.to_string()));
439
440 let parsed = match parse(content, Mode::Module, "") {
442 Ok(ast) => ast,
443 Err(e) => {
444 error!("Failed to parse Python file {:?}: {}", file_path, e);
445 return;
446 }
447 };
448
449 self.usages.remove(&file_path);
451
452 self.undeclared_fixtures.remove(&file_path);
454
455 self.imports.remove(&file_path);
457
458 let keys: Vec<String> = {
466 let mut k = Vec::new();
467 for entry in self.definitions.iter() {
468 k.push(entry.key().clone());
469 }
470 k
471 }; for key in keys {
475 let current_defs = match self.definitions.get(&key) {
477 Some(defs) => defs.clone(),
478 None => continue,
479 };
480
481 let filtered: Vec<FixtureDefinition> = current_defs
483 .iter()
484 .filter(|def| def.file_path != file_path)
485 .cloned()
486 .collect();
487
488 if filtered.is_empty() {
490 self.definitions.remove(&key);
491 } else if filtered.len() != current_defs.len() {
492 self.definitions.insert(key, filtered);
494 }
495 }
496
497 let is_conftest = file_path
499 .file_name()
500 .map(|n| n == "conftest.py")
501 .unwrap_or(false);
502 debug!("is_conftest: {}", is_conftest);
503
504 let line_index = Self::build_line_index(content);
506
507 if let rustpython_parser::ast::Mod::Module(module) = parsed {
509 debug!("Module has {} statements", module.body.len());
510
511 let mut module_level_names = std::collections::HashSet::new();
513 for stmt in &module.body {
514 self.collect_module_level_names(stmt, &mut module_level_names);
515 }
516 self.imports.insert(file_path.clone(), module_level_names);
517
518 for stmt in &module.body {
520 self.visit_stmt(stmt, &file_path, is_conftest, content, &line_index);
521 }
522 }
523
524 debug!("Analysis complete for {:?}", file_path);
525 }
526
527 fn visit_stmt(
528 &self,
529 stmt: &Stmt,
530 file_path: &PathBuf,
531 _is_conftest: bool,
532 content: &str,
533 line_index: &[usize],
534 ) {
535 if let Stmt::Assign(assign) = stmt {
537 self.visit_assignment_fixture(assign, file_path, content, line_index);
538 }
539
540 let (func_name, decorator_list, args, range, body, returns) = match stmt {
542 Stmt::FunctionDef(func_def) => (
543 func_def.name.as_str(),
544 &func_def.decorator_list,
545 &func_def.args,
546 func_def.range,
547 &func_def.body,
548 &func_def.returns,
549 ),
550 Stmt::AsyncFunctionDef(func_def) => (
551 func_def.name.as_str(),
552 &func_def.decorator_list,
553 &func_def.args,
554 func_def.range,
555 &func_def.body,
556 &func_def.returns,
557 ),
558 _ => return,
559 };
560
561 debug!("Found function: {}", func_name);
562
563 debug!(
565 "Function {} has {} decorators",
566 func_name,
567 decorator_list.len()
568 );
569 let is_fixture = decorator_list.iter().any(|dec| {
570 let result = Self::is_fixture_decorator(dec);
571 if result {
572 debug!(" Decorator matched as fixture!");
573 }
574 result
575 });
576
577 if is_fixture {
578 let line = self.get_line_from_offset(range.start().to_usize(), line_index);
580
581 let docstring = self.extract_docstring(body);
583
584 let return_type = self.extract_return_type(returns, body, content);
586
587 info!(
588 "Found fixture definition: {} at {:?}:{}",
589 func_name, file_path, line
590 );
591 if let Some(ref doc) = docstring {
592 debug!(" Docstring: {}", doc);
593 }
594 if let Some(ref ret_type) = return_type {
595 debug!(" Return type: {}", ret_type);
596 }
597
598 let definition = FixtureDefinition {
599 name: func_name.to_string(),
600 file_path: file_path.clone(),
601 line,
602 docstring,
603 return_type,
604 };
605
606 self.definitions
607 .entry(func_name.to_string())
608 .or_default()
609 .push(definition);
610
611 let mut declared_params: std::collections::HashSet<String> =
613 std::collections::HashSet::new();
614 declared_params.insert("self".to_string());
615 declared_params.insert("request".to_string());
616 declared_params.insert(func_name.to_string()); for arg in &args.args {
619 let arg_name = arg.def.arg.as_str();
620 declared_params.insert(arg_name.to_string());
621
622 if arg_name != "self" && arg_name != "request" {
623 let arg_line =
626 self.get_line_from_offset(arg.def.range.start().to_usize(), line_index);
627 let start_char = self.get_char_position_from_offset(
628 arg.def.range.start().to_usize(),
629 line_index,
630 );
631 let end_char = self
632 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
633
634 info!(
635 "Found fixture dependency: {} at {:?}:{}:{}",
636 arg_name, file_path, arg_line, start_char
637 );
638
639 let usage = FixtureUsage {
640 name: arg_name.to_string(),
641 file_path: file_path.clone(),
642 line: arg_line, start_char,
644 end_char,
645 };
646
647 self.usages
648 .entry(file_path.clone())
649 .or_default()
650 .push(usage);
651 }
652 }
653
654 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
656 self.scan_function_body_for_undeclared_fixtures(
657 body,
658 file_path,
659 content,
660 line_index,
661 &declared_params,
662 func_name,
663 function_line,
664 );
665 }
666
667 let is_test = func_name.starts_with("test_");
669
670 if is_test {
671 debug!("Found test function: {}", func_name);
672
673 let mut declared_params: std::collections::HashSet<String> =
675 std::collections::HashSet::new();
676 declared_params.insert("self".to_string());
677 declared_params.insert("request".to_string()); for arg in &args.args {
681 let arg_name = arg.def.arg.as_str();
682 declared_params.insert(arg_name.to_string());
683
684 if arg_name != "self" {
685 let arg_offset = arg.def.range.start().to_usize();
689 let arg_line = self.get_line_from_offset(arg_offset, line_index);
690 let start_char = self.get_char_position_from_offset(arg_offset, line_index);
691 let end_char = self
692 .get_char_position_from_offset(arg.def.range.end().to_usize(), line_index);
693
694 debug!(
695 "Parameter {} at offset {}, calculated line {}, char {}",
696 arg_name, arg_offset, arg_line, start_char
697 );
698 info!(
699 "Found fixture usage: {} at {:?}:{}:{}",
700 arg_name, file_path, arg_line, start_char
701 );
702
703 let usage = FixtureUsage {
704 name: arg_name.to_string(),
705 file_path: file_path.clone(),
706 line: arg_line, start_char,
708 end_char,
709 };
710
711 self.usages
713 .entry(file_path.clone())
714 .or_default()
715 .push(usage);
716 }
717 }
718
719 let function_line = self.get_line_from_offset(range.start().to_usize(), line_index);
721 self.scan_function_body_for_undeclared_fixtures(
722 body,
723 file_path,
724 content,
725 line_index,
726 &declared_params,
727 func_name,
728 function_line,
729 );
730 }
731 }
732
733 fn visit_assignment_fixture(
734 &self,
735 assign: &rustpython_parser::ast::StmtAssign,
736 file_path: &PathBuf,
737 _content: &str,
738 line_index: &[usize],
739 ) {
740 if let Expr::Call(outer_call) = &*assign.value {
744 if let Expr::Call(inner_call) = &*outer_call.func {
746 if Self::is_fixture_decorator(&inner_call.func) {
747 for target in &assign.targets {
750 if let Expr::Name(name) = target {
751 let fixture_name = name.id.as_str();
752 let line = self
753 .get_line_from_offset(assign.range.start().to_usize(), line_index);
754
755 info!(
756 "Found fixture assignment: {} at {:?}:{}",
757 fixture_name, file_path, line
758 );
759
760 let definition = FixtureDefinition {
762 name: fixture_name.to_string(),
763 file_path: file_path.clone(),
764 line,
765 docstring: None,
766 return_type: None,
767 };
768
769 self.definitions
770 .entry(fixture_name.to_string())
771 .or_default()
772 .push(definition);
773 }
774 }
775 }
776 }
777 }
778 }
779
780 fn is_fixture_decorator(expr: &Expr) -> bool {
781 match expr {
782 Expr::Name(name) => name.id.as_str() == "fixture",
783 Expr::Attribute(attr) => {
784 if let Expr::Name(value) = &*attr.value {
786 value.id.as_str() == "pytest" && attr.attr.as_str() == "fixture"
787 } else {
788 false
789 }
790 }
791 Expr::Call(call) => {
792 Self::is_fixture_decorator(&call.func)
794 }
795 _ => false,
796 }
797 }
798
799 #[allow(clippy::too_many_arguments)]
800 fn scan_function_body_for_undeclared_fixtures(
801 &self,
802 body: &[Stmt],
803 file_path: &PathBuf,
804 content: &str,
805 line_index: &[usize],
806 declared_params: &std::collections::HashSet<String>,
807 function_name: &str,
808 function_line: usize,
809 ) {
810 let mut local_vars = std::collections::HashMap::new();
812 self.collect_local_variables(body, content, line_index, &mut local_vars);
813
814 if let Some(imports) = self.imports.get(file_path) {
817 for import in imports.iter() {
818 local_vars.insert(import.clone(), 0);
819 }
820 }
821
822 for stmt in body {
824 self.visit_stmt_for_names(
825 stmt,
826 file_path,
827 content,
828 line_index,
829 declared_params,
830 &local_vars,
831 function_name,
832 function_line,
833 );
834 }
835 }
836
837 fn collect_module_level_names(
838 &self,
839 stmt: &Stmt,
840 names: &mut std::collections::HashSet<String>,
841 ) {
842 match stmt {
843 Stmt::Import(import_stmt) => {
845 for alias in &import_stmt.names {
846 let name = alias.asname.as_ref().unwrap_or(&alias.name);
848 names.insert(name.to_string());
849 }
850 }
851 Stmt::ImportFrom(import_from) => {
852 for alias in &import_from.names {
853 let name = alias.asname.as_ref().unwrap_or(&alias.name);
855 names.insert(name.to_string());
856 }
857 }
858 Stmt::FunctionDef(func_def) => {
860 let is_fixture = func_def
862 .decorator_list
863 .iter()
864 .any(Self::is_fixture_decorator);
865 if !is_fixture {
866 names.insert(func_def.name.to_string());
867 }
868 }
869 Stmt::AsyncFunctionDef(func_def) => {
871 let is_fixture = func_def
872 .decorator_list
873 .iter()
874 .any(Self::is_fixture_decorator);
875 if !is_fixture {
876 names.insert(func_def.name.to_string());
877 }
878 }
879 Stmt::ClassDef(class_def) => {
881 names.insert(class_def.name.to_string());
882 }
883 Stmt::Assign(assign) => {
885 for target in &assign.targets {
886 self.collect_names_from_expr(target, names);
887 }
888 }
889 Stmt::AnnAssign(ann_assign) => {
890 self.collect_names_from_expr(&ann_assign.target, names);
891 }
892 _ => {}
893 }
894 }
895
896 #[allow(clippy::only_used_in_recursion)]
897 fn collect_local_variables(
898 &self,
899 body: &[Stmt],
900 content: &str,
901 line_index: &[usize],
902 local_vars: &mut std::collections::HashMap<String, usize>,
903 ) {
904 for stmt in body {
905 match stmt {
906 Stmt::Assign(assign) => {
907 let line =
909 self.get_line_from_offset(assign.range.start().to_usize(), line_index);
910 let mut temp_names = std::collections::HashSet::new();
911 for target in &assign.targets {
912 self.collect_names_from_expr(target, &mut temp_names);
913 }
914 for name in temp_names {
915 local_vars.insert(name, line);
916 }
917 }
918 Stmt::AnnAssign(ann_assign) => {
919 let line =
921 self.get_line_from_offset(ann_assign.range.start().to_usize(), line_index);
922 let mut temp_names = std::collections::HashSet::new();
923 self.collect_names_from_expr(&ann_assign.target, &mut temp_names);
924 for name in temp_names {
925 local_vars.insert(name, line);
926 }
927 }
928 Stmt::AugAssign(aug_assign) => {
929 let line =
931 self.get_line_from_offset(aug_assign.range.start().to_usize(), line_index);
932 let mut temp_names = std::collections::HashSet::new();
933 self.collect_names_from_expr(&aug_assign.target, &mut temp_names);
934 for name in temp_names {
935 local_vars.insert(name, line);
936 }
937 }
938 Stmt::For(for_stmt) => {
939 let line =
941 self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
942 let mut temp_names = std::collections::HashSet::new();
943 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
944 for name in temp_names {
945 local_vars.insert(name, line);
946 }
947 self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
949 }
950 Stmt::AsyncFor(for_stmt) => {
951 let line =
952 self.get_line_from_offset(for_stmt.range.start().to_usize(), line_index);
953 let mut temp_names = std::collections::HashSet::new();
954 self.collect_names_from_expr(&for_stmt.target, &mut temp_names);
955 for name in temp_names {
956 local_vars.insert(name, line);
957 }
958 self.collect_local_variables(&for_stmt.body, content, line_index, local_vars);
959 }
960 Stmt::While(while_stmt) => {
961 self.collect_local_variables(&while_stmt.body, content, line_index, local_vars);
962 }
963 Stmt::If(if_stmt) => {
964 self.collect_local_variables(&if_stmt.body, content, line_index, local_vars);
965 self.collect_local_variables(&if_stmt.orelse, content, line_index, local_vars);
966 }
967 Stmt::With(with_stmt) => {
968 let line =
970 self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
971 for item in &with_stmt.items {
972 if let Some(ref optional_vars) = item.optional_vars {
973 let mut temp_names = std::collections::HashSet::new();
974 self.collect_names_from_expr(optional_vars, &mut temp_names);
975 for name in temp_names {
976 local_vars.insert(name, line);
977 }
978 }
979 }
980 self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
981 }
982 Stmt::AsyncWith(with_stmt) => {
983 let line =
984 self.get_line_from_offset(with_stmt.range.start().to_usize(), line_index);
985 for item in &with_stmt.items {
986 if let Some(ref optional_vars) = item.optional_vars {
987 let mut temp_names = std::collections::HashSet::new();
988 self.collect_names_from_expr(optional_vars, &mut temp_names);
989 for name in temp_names {
990 local_vars.insert(name, line);
991 }
992 }
993 }
994 self.collect_local_variables(&with_stmt.body, content, line_index, local_vars);
995 }
996 Stmt::Try(try_stmt) => {
997 self.collect_local_variables(&try_stmt.body, content, line_index, local_vars);
998 self.collect_local_variables(&try_stmt.orelse, content, line_index, local_vars);
1002 self.collect_local_variables(
1003 &try_stmt.finalbody,
1004 content,
1005 line_index,
1006 local_vars,
1007 );
1008 }
1009 _ => {}
1010 }
1011 }
1012 }
1013
1014 #[allow(clippy::only_used_in_recursion)]
1015 fn collect_names_from_expr(&self, expr: &Expr, names: &mut std::collections::HashSet<String>) {
1016 match expr {
1017 Expr::Name(name) => {
1018 names.insert(name.id.to_string());
1019 }
1020 Expr::Tuple(tuple) => {
1021 for elt in &tuple.elts {
1022 self.collect_names_from_expr(elt, names);
1023 }
1024 }
1025 Expr::List(list) => {
1026 for elt in &list.elts {
1027 self.collect_names_from_expr(elt, names);
1028 }
1029 }
1030 _ => {}
1031 }
1032 }
1033
1034 #[allow(clippy::too_many_arguments)]
1035 fn visit_stmt_for_names(
1036 &self,
1037 stmt: &Stmt,
1038 file_path: &PathBuf,
1039 content: &str,
1040 line_index: &[usize],
1041 declared_params: &std::collections::HashSet<String>,
1042 local_vars: &std::collections::HashMap<String, usize>,
1043 function_name: &str,
1044 function_line: usize,
1045 ) {
1046 match stmt {
1047 Stmt::Expr(expr_stmt) => {
1048 self.visit_expr_for_names(
1049 &expr_stmt.value,
1050 file_path,
1051 content,
1052 line_index,
1053 declared_params,
1054 local_vars,
1055 function_name,
1056 function_line,
1057 );
1058 }
1059 Stmt::Assign(assign) => {
1060 self.visit_expr_for_names(
1061 &assign.value,
1062 file_path,
1063 content,
1064 line_index,
1065 declared_params,
1066 local_vars,
1067 function_name,
1068 function_line,
1069 );
1070 }
1071 Stmt::AugAssign(aug_assign) => {
1072 self.visit_expr_for_names(
1073 &aug_assign.value,
1074 file_path,
1075 content,
1076 line_index,
1077 declared_params,
1078 local_vars,
1079 function_name,
1080 function_line,
1081 );
1082 }
1083 Stmt::Return(ret) => {
1084 if let Some(ref value) = ret.value {
1085 self.visit_expr_for_names(
1086 value,
1087 file_path,
1088 content,
1089 line_index,
1090 declared_params,
1091 local_vars,
1092 function_name,
1093 function_line,
1094 );
1095 }
1096 }
1097 Stmt::If(if_stmt) => {
1098 self.visit_expr_for_names(
1099 &if_stmt.test,
1100 file_path,
1101 content,
1102 line_index,
1103 declared_params,
1104 local_vars,
1105 function_name,
1106 function_line,
1107 );
1108 for stmt in &if_stmt.body {
1109 self.visit_stmt_for_names(
1110 stmt,
1111 file_path,
1112 content,
1113 line_index,
1114 declared_params,
1115 local_vars,
1116 function_name,
1117 function_line,
1118 );
1119 }
1120 for stmt in &if_stmt.orelse {
1121 self.visit_stmt_for_names(
1122 stmt,
1123 file_path,
1124 content,
1125 line_index,
1126 declared_params,
1127 local_vars,
1128 function_name,
1129 function_line,
1130 );
1131 }
1132 }
1133 Stmt::While(while_stmt) => {
1134 self.visit_expr_for_names(
1135 &while_stmt.test,
1136 file_path,
1137 content,
1138 line_index,
1139 declared_params,
1140 local_vars,
1141 function_name,
1142 function_line,
1143 );
1144 for stmt in &while_stmt.body {
1145 self.visit_stmt_for_names(
1146 stmt,
1147 file_path,
1148 content,
1149 line_index,
1150 declared_params,
1151 local_vars,
1152 function_name,
1153 function_line,
1154 );
1155 }
1156 }
1157 Stmt::For(for_stmt) => {
1158 self.visit_expr_for_names(
1159 &for_stmt.iter,
1160 file_path,
1161 content,
1162 line_index,
1163 declared_params,
1164 local_vars,
1165 function_name,
1166 function_line,
1167 );
1168 for stmt in &for_stmt.body {
1169 self.visit_stmt_for_names(
1170 stmt,
1171 file_path,
1172 content,
1173 line_index,
1174 declared_params,
1175 local_vars,
1176 function_name,
1177 function_line,
1178 );
1179 }
1180 }
1181 Stmt::With(with_stmt) => {
1182 for item in &with_stmt.items {
1183 self.visit_expr_for_names(
1184 &item.context_expr,
1185 file_path,
1186 content,
1187 line_index,
1188 declared_params,
1189 local_vars,
1190 function_name,
1191 function_line,
1192 );
1193 }
1194 for stmt in &with_stmt.body {
1195 self.visit_stmt_for_names(
1196 stmt,
1197 file_path,
1198 content,
1199 line_index,
1200 declared_params,
1201 local_vars,
1202 function_name,
1203 function_line,
1204 );
1205 }
1206 }
1207 Stmt::AsyncFor(for_stmt) => {
1208 self.visit_expr_for_names(
1209 &for_stmt.iter,
1210 file_path,
1211 content,
1212 line_index,
1213 declared_params,
1214 local_vars,
1215 function_name,
1216 function_line,
1217 );
1218 for stmt in &for_stmt.body {
1219 self.visit_stmt_for_names(
1220 stmt,
1221 file_path,
1222 content,
1223 line_index,
1224 declared_params,
1225 local_vars,
1226 function_name,
1227 function_line,
1228 );
1229 }
1230 }
1231 Stmt::AsyncWith(with_stmt) => {
1232 for item in &with_stmt.items {
1233 self.visit_expr_for_names(
1234 &item.context_expr,
1235 file_path,
1236 content,
1237 line_index,
1238 declared_params,
1239 local_vars,
1240 function_name,
1241 function_line,
1242 );
1243 }
1244 for stmt in &with_stmt.body {
1245 self.visit_stmt_for_names(
1246 stmt,
1247 file_path,
1248 content,
1249 line_index,
1250 declared_params,
1251 local_vars,
1252 function_name,
1253 function_line,
1254 );
1255 }
1256 }
1257 Stmt::Assert(assert_stmt) => {
1258 self.visit_expr_for_names(
1259 &assert_stmt.test,
1260 file_path,
1261 content,
1262 line_index,
1263 declared_params,
1264 local_vars,
1265 function_name,
1266 function_line,
1267 );
1268 if let Some(ref msg) = assert_stmt.msg {
1269 self.visit_expr_for_names(
1270 msg,
1271 file_path,
1272 content,
1273 line_index,
1274 declared_params,
1275 local_vars,
1276 function_name,
1277 function_line,
1278 );
1279 }
1280 }
1281 _ => {} }
1283 }
1284
1285 #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
1286 fn visit_expr_for_names(
1287 &self,
1288 expr: &Expr,
1289 file_path: &PathBuf,
1290 content: &str,
1291 line_index: &[usize],
1292 declared_params: &std::collections::HashSet<String>,
1293 local_vars: &std::collections::HashMap<String, usize>,
1294 function_name: &str,
1295 function_line: usize,
1296 ) {
1297 match expr {
1298 Expr::Name(name) => {
1299 let name_str = name.id.as_str();
1300 let line = self.get_line_from_offset(name.range.start().to_usize(), line_index);
1301
1302 let is_local_var_in_scope = local_vars
1306 .get(name_str)
1307 .map(|def_line| *def_line < line)
1308 .unwrap_or(false);
1309
1310 if !declared_params.contains(name_str)
1311 && !is_local_var_in_scope
1312 && self.is_available_fixture(file_path, name_str)
1313 {
1314 let start_char = self
1315 .get_char_position_from_offset(name.range.start().to_usize(), line_index);
1316 let end_char =
1317 self.get_char_position_from_offset(name.range.end().to_usize(), line_index);
1318
1319 info!(
1320 "Found undeclared fixture usage: {} at {:?}:{}:{} in function {}",
1321 name_str, file_path, line, start_char, function_name
1322 );
1323
1324 let undeclared = UndeclaredFixture {
1325 name: name_str.to_string(),
1326 file_path: file_path.clone(),
1327 line,
1328 start_char,
1329 end_char,
1330 function_name: function_name.to_string(),
1331 function_line,
1332 };
1333
1334 self.undeclared_fixtures
1335 .entry(file_path.clone())
1336 .or_default()
1337 .push(undeclared);
1338 }
1339 }
1340 Expr::Call(call) => {
1341 self.visit_expr_for_names(
1342 &call.func,
1343 file_path,
1344 content,
1345 line_index,
1346 declared_params,
1347 local_vars,
1348 function_name,
1349 function_line,
1350 );
1351 for arg in &call.args {
1352 self.visit_expr_for_names(
1353 arg,
1354 file_path,
1355 content,
1356 line_index,
1357 declared_params,
1358 local_vars,
1359 function_name,
1360 function_line,
1361 );
1362 }
1363 }
1364 Expr::Attribute(attr) => {
1365 self.visit_expr_for_names(
1366 &attr.value,
1367 file_path,
1368 content,
1369 line_index,
1370 declared_params,
1371 local_vars,
1372 function_name,
1373 function_line,
1374 );
1375 }
1376 Expr::BinOp(binop) => {
1377 self.visit_expr_for_names(
1378 &binop.left,
1379 file_path,
1380 content,
1381 line_index,
1382 declared_params,
1383 local_vars,
1384 function_name,
1385 function_line,
1386 );
1387 self.visit_expr_for_names(
1388 &binop.right,
1389 file_path,
1390 content,
1391 line_index,
1392 declared_params,
1393 local_vars,
1394 function_name,
1395 function_line,
1396 );
1397 }
1398 Expr::UnaryOp(unaryop) => {
1399 self.visit_expr_for_names(
1400 &unaryop.operand,
1401 file_path,
1402 content,
1403 line_index,
1404 declared_params,
1405 local_vars,
1406 function_name,
1407 function_line,
1408 );
1409 }
1410 Expr::Compare(compare) => {
1411 self.visit_expr_for_names(
1412 &compare.left,
1413 file_path,
1414 content,
1415 line_index,
1416 declared_params,
1417 local_vars,
1418 function_name,
1419 function_line,
1420 );
1421 for comparator in &compare.comparators {
1422 self.visit_expr_for_names(
1423 comparator,
1424 file_path,
1425 content,
1426 line_index,
1427 declared_params,
1428 local_vars,
1429 function_name,
1430 function_line,
1431 );
1432 }
1433 }
1434 Expr::Subscript(subscript) => {
1435 self.visit_expr_for_names(
1436 &subscript.value,
1437 file_path,
1438 content,
1439 line_index,
1440 declared_params,
1441 local_vars,
1442 function_name,
1443 function_line,
1444 );
1445 self.visit_expr_for_names(
1446 &subscript.slice,
1447 file_path,
1448 content,
1449 line_index,
1450 declared_params,
1451 local_vars,
1452 function_name,
1453 function_line,
1454 );
1455 }
1456 Expr::List(list) => {
1457 for elt in &list.elts {
1458 self.visit_expr_for_names(
1459 elt,
1460 file_path,
1461 content,
1462 line_index,
1463 declared_params,
1464 local_vars,
1465 function_name,
1466 function_line,
1467 );
1468 }
1469 }
1470 Expr::Tuple(tuple) => {
1471 for elt in &tuple.elts {
1472 self.visit_expr_for_names(
1473 elt,
1474 file_path,
1475 content,
1476 line_index,
1477 declared_params,
1478 local_vars,
1479 function_name,
1480 function_line,
1481 );
1482 }
1483 }
1484 Expr::Dict(dict) => {
1485 for k in dict.keys.iter().flatten() {
1486 self.visit_expr_for_names(
1487 k,
1488 file_path,
1489 content,
1490 line_index,
1491 declared_params,
1492 local_vars,
1493 function_name,
1494 function_line,
1495 );
1496 }
1497 for value in &dict.values {
1498 self.visit_expr_for_names(
1499 value,
1500 file_path,
1501 content,
1502 line_index,
1503 declared_params,
1504 local_vars,
1505 function_name,
1506 function_line,
1507 );
1508 }
1509 }
1510 Expr::Await(await_expr) => {
1511 self.visit_expr_for_names(
1513 &await_expr.value,
1514 file_path,
1515 content,
1516 line_index,
1517 declared_params,
1518 local_vars,
1519 function_name,
1520 function_line,
1521 );
1522 }
1523 _ => {} }
1525 }
1526
1527 fn is_available_fixture(&self, file_path: &Path, fixture_name: &str) -> bool {
1528 if let Some(definitions) = self.definitions.get(fixture_name) {
1530 for def in definitions.iter() {
1532 if def.file_path == file_path {
1534 return true;
1535 }
1536
1537 if def.file_path.file_name().and_then(|n| n.to_str()) == Some("conftest.py")
1539 && file_path.starts_with(def.file_path.parent().unwrap_or(Path::new("")))
1540 {
1541 return true;
1542 }
1543
1544 if def.file_path.to_string_lossy().contains("site-packages") {
1546 return true;
1547 }
1548 }
1549 }
1550 false
1551 }
1552
1553 fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
1554 if let Some(Stmt::Expr(expr_stmt)) = body.first() {
1556 if let Expr::Constant(constant) = &*expr_stmt.value {
1557 if let rustpython_parser::ast::Constant::Str(s) = &constant.value {
1559 return Some(self.format_docstring(s.to_string()));
1560 }
1561 }
1562 }
1563 None
1564 }
1565
1566 fn format_docstring(&self, docstring: String) -> String {
1567 let lines: Vec<&str> = docstring.lines().collect();
1570
1571 if lines.is_empty() {
1572 return String::new();
1573 }
1574
1575 let mut start = 0;
1577 let mut end = lines.len();
1578
1579 while start < lines.len() && lines[start].trim().is_empty() {
1580 start += 1;
1581 }
1582
1583 while end > start && lines[end - 1].trim().is_empty() {
1584 end -= 1;
1585 }
1586
1587 if start >= end {
1588 return String::new();
1589 }
1590
1591 let lines = &lines[start..end];
1592
1593 let mut min_indent = usize::MAX;
1595 for (i, line) in lines.iter().enumerate() {
1596 if i == 0 && !line.trim().is_empty() {
1597 continue;
1599 }
1600
1601 if !line.trim().is_empty() {
1602 let indent = line.len() - line.trim_start().len();
1603 min_indent = min_indent.min(indent);
1604 }
1605 }
1606
1607 if min_indent == usize::MAX {
1608 min_indent = 0;
1609 }
1610
1611 let mut result = Vec::new();
1613 for (i, line) in lines.iter().enumerate() {
1614 if i == 0 {
1615 result.push(line.trim().to_string());
1617 } else if line.trim().is_empty() {
1618 result.push(String::new());
1620 } else {
1621 let dedented = if line.len() > min_indent {
1623 &line[min_indent..]
1624 } else {
1625 line.trim_start()
1626 };
1627 result.push(dedented.to_string());
1628 }
1629 }
1630
1631 result.join("\n")
1633 }
1634
1635 fn extract_return_type(
1636 &self,
1637 returns: &Option<Box<rustpython_parser::ast::Expr>>,
1638 body: &[Stmt],
1639 content: &str,
1640 ) -> Option<String> {
1641 if let Some(return_expr) = returns {
1642 let has_yield = self.contains_yield(body);
1644
1645 if has_yield {
1646 return self.extract_yielded_type(return_expr, content);
1649 } else {
1650 return Some(self.expr_to_string(return_expr, content));
1652 }
1653 }
1654 None
1655 }
1656
1657 #[allow(clippy::only_used_in_recursion)]
1658 fn contains_yield(&self, body: &[Stmt]) -> bool {
1659 for stmt in body {
1660 match stmt {
1661 Stmt::Expr(expr_stmt) => {
1662 if let Expr::Yield(_) | Expr::YieldFrom(_) = &*expr_stmt.value {
1663 return true;
1664 }
1665 }
1666 Stmt::If(if_stmt) => {
1667 if self.contains_yield(&if_stmt.body) || self.contains_yield(&if_stmt.orelse) {
1668 return true;
1669 }
1670 }
1671 Stmt::For(for_stmt) => {
1672 if self.contains_yield(&for_stmt.body) || self.contains_yield(&for_stmt.orelse)
1673 {
1674 return true;
1675 }
1676 }
1677 Stmt::While(while_stmt) => {
1678 if self.contains_yield(&while_stmt.body)
1679 || self.contains_yield(&while_stmt.orelse)
1680 {
1681 return true;
1682 }
1683 }
1684 Stmt::With(with_stmt) => {
1685 if self.contains_yield(&with_stmt.body) {
1686 return true;
1687 }
1688 }
1689 Stmt::Try(try_stmt) => {
1690 if self.contains_yield(&try_stmt.body)
1691 || self.contains_yield(&try_stmt.orelse)
1692 || self.contains_yield(&try_stmt.finalbody)
1693 {
1694 return true;
1695 }
1696 }
1699 _ => {}
1700 }
1701 }
1702 false
1703 }
1704
1705 fn extract_yielded_type(
1706 &self,
1707 expr: &rustpython_parser::ast::Expr,
1708 content: &str,
1709 ) -> Option<String> {
1710 if let Expr::Subscript(subscript) = expr {
1714 let _base_name = self.expr_to_string(&subscript.value, content);
1716
1717 if let Expr::Tuple(tuple) = &*subscript.slice {
1719 if let Some(first_elem) = tuple.elts.first() {
1720 return Some(self.expr_to_string(first_elem, content));
1721 }
1722 } else {
1723 return Some(self.expr_to_string(&subscript.slice, content));
1725 }
1726 }
1727
1728 Some(self.expr_to_string(expr, content))
1730 }
1731
1732 #[allow(clippy::only_used_in_recursion)]
1733 fn expr_to_string(&self, expr: &rustpython_parser::ast::Expr, content: &str) -> String {
1734 match expr {
1735 Expr::Name(name) => name.id.to_string(),
1736 Expr::Attribute(attr) => {
1737 format!(
1738 "{}.{}",
1739 self.expr_to_string(&attr.value, content),
1740 attr.attr
1741 )
1742 }
1743 Expr::Subscript(subscript) => {
1744 let base = self.expr_to_string(&subscript.value, content);
1745 let slice = self.expr_to_string(&subscript.slice, content);
1746 format!("{}[{}]", base, slice)
1747 }
1748 Expr::Tuple(tuple) => {
1749 let elements: Vec<String> = tuple
1750 .elts
1751 .iter()
1752 .map(|e| self.expr_to_string(e, content))
1753 .collect();
1754 elements.join(", ")
1755 }
1756 Expr::Constant(constant) => {
1757 format!("{:?}", constant.value)
1758 }
1759 Expr::BinOp(binop) if matches!(binop.op, rustpython_parser::ast::Operator::BitOr) => {
1760 format!(
1762 "{} | {}",
1763 self.expr_to_string(&binop.left, content),
1764 self.expr_to_string(&binop.right, content)
1765 )
1766 }
1767 _ => {
1768 "Any".to_string()
1770 }
1771 }
1772 }
1773
1774 fn build_line_index(content: &str) -> Vec<usize> {
1775 let mut line_index = Vec::with_capacity(content.len() / 30);
1776 line_index.push(0);
1777 for (i, c) in content.char_indices() {
1778 if c == '\n' {
1779 line_index.push(i + 1);
1780 }
1781 }
1782 line_index
1783 }
1784
1785 fn get_line_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
1786 match line_index.binary_search(&offset) {
1787 Ok(line) => line + 1,
1788 Err(line) => line,
1789 }
1790 }
1791
1792 fn get_char_position_from_offset(&self, offset: usize, line_index: &[usize]) -> usize {
1793 let line = self.get_line_from_offset(offset, line_index);
1794 let line_start = line_index[line - 1];
1795 offset.saturating_sub(line_start)
1796 }
1797
1798 pub fn find_fixture_definition(
1800 &self,
1801 file_path: &Path,
1802 line: u32,
1803 character: u32,
1804 ) -> Option<FixtureDefinition> {
1805 debug!(
1806 "find_fixture_definition: file={:?}, line={}, char={}",
1807 file_path, line, character
1808 );
1809
1810 let target_line = (line + 1) as usize; let content = self.get_file_content(file_path)?;
1815
1816 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
1818 debug!("Line content: {}", line_content);
1819
1820 let word_at_cursor = self.extract_word_at_position(line_content, character as usize)?;
1822 debug!("Word at cursor: {:?}", word_at_cursor);
1823
1824 let current_fixture_def = self.get_fixture_definition_at_line(file_path, target_line);
1827
1828 if let Some(usages) = self.usages.get(file_path) {
1831 for usage in usages.iter() {
1832 if usage.line == target_line && usage.name == word_at_cursor {
1833 let cursor_pos = character as usize;
1835 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
1836 debug!(
1837 "Cursor at {} is within usage range {}-{}: {}",
1838 cursor_pos, usage.start_char, usage.end_char, usage.name
1839 );
1840 info!("Found fixture usage at cursor position: {}", usage.name);
1841
1842 if let Some(ref current_def) = current_fixture_def {
1844 if current_def.name == word_at_cursor {
1845 info!(
1846 "Self-referencing fixture detected, finding parent definition"
1847 );
1848 return self.find_closest_definition_excluding(
1849 file_path,
1850 &usage.name,
1851 Some(current_def),
1852 );
1853 }
1854 }
1855
1856 return self.find_closest_definition(file_path, &usage.name);
1858 }
1859 }
1860 }
1861 }
1862
1863 debug!("Word at cursor '{}' is not a fixture usage", word_at_cursor);
1864 None
1865 }
1866
1867 fn get_fixture_definition_at_line(
1869 &self,
1870 file_path: &Path,
1871 line: usize,
1872 ) -> Option<FixtureDefinition> {
1873 for entry in self.definitions.iter() {
1874 for def in entry.value().iter() {
1875 if def.file_path == file_path && def.line == line {
1876 return Some(def.clone());
1877 }
1878 }
1879 }
1880 None
1881 }
1882
1883 pub fn get_definition_at_line(
1886 &self,
1887 file_path: &Path,
1888 line: usize,
1889 fixture_name: &str,
1890 ) -> Option<FixtureDefinition> {
1891 if let Some(definitions) = self.definitions.get(fixture_name) {
1892 for def in definitions.iter() {
1893 if def.file_path == file_path && def.line == line {
1894 return Some(def.clone());
1895 }
1896 }
1897 }
1898 None
1899 }
1900
1901 fn find_closest_definition(
1902 &self,
1903 file_path: &Path,
1904 fixture_name: &str,
1905 ) -> Option<FixtureDefinition> {
1906 let definitions = self.definitions.get(fixture_name)?;
1907
1908 debug!(
1911 "Checking for fixture {} in same file: {:?}",
1912 fixture_name, file_path
1913 );
1914
1915 if let Some(last_def) = definitions
1917 .iter()
1918 .filter(|def| def.file_path == file_path)
1919 .max_by_key(|def| def.line)
1920 {
1921 info!(
1922 "Found fixture {} in same file at line {} (using last definition)",
1923 fixture_name, last_def.line
1924 );
1925 return Some(last_def.clone());
1926 }
1927
1928 let mut current_dir = file_path.parent()?;
1931
1932 debug!(
1933 "Searching for fixture {} in conftest.py files starting from {:?}",
1934 fixture_name, current_dir
1935 );
1936 loop {
1937 let conftest_path = current_dir.join("conftest.py");
1939 debug!(" Checking conftest.py at: {:?}", conftest_path);
1940
1941 for def in definitions.iter() {
1942 if def.file_path == conftest_path {
1943 info!(
1944 "Found fixture {} in conftest.py: {:?}",
1945 fixture_name, conftest_path
1946 );
1947 return Some(def.clone());
1948 }
1949 }
1950
1951 match current_dir.parent() {
1953 Some(parent) => current_dir = parent,
1954 None => break,
1955 }
1956 }
1957
1958 debug!(
1961 "No fixture {} found in conftest hierarchy, checking for third-party fixtures",
1962 fixture_name
1963 );
1964 for def in definitions.iter() {
1965 if def.file_path.to_string_lossy().contains("site-packages") {
1966 info!(
1967 "Found third-party fixture {} in site-packages: {:?}",
1968 fixture_name, def.file_path
1969 );
1970 return Some(def.clone());
1971 }
1972 }
1973
1974 warn!(
1979 "No fixture {} found following priority rules (same file, conftest hierarchy, third-party)",
1980 fixture_name
1981 );
1982 warn!(
1983 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
1984 );
1985
1986 let mut defs: Vec<_> = definitions.iter().cloned().collect();
1987 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
1988 defs.first().cloned()
1989 }
1990
1991 fn find_closest_definition_excluding(
1994 &self,
1995 file_path: &Path,
1996 fixture_name: &str,
1997 exclude: Option<&FixtureDefinition>,
1998 ) -> Option<FixtureDefinition> {
1999 let definitions = self.definitions.get(fixture_name)?;
2000
2001 debug!(
2005 "Checking for fixture {} in same file: {:?} (excluding: {:?})",
2006 fixture_name, file_path, exclude
2007 );
2008
2009 if let Some(last_def) = definitions
2011 .iter()
2012 .filter(|def| {
2013 if def.file_path != file_path {
2014 return false;
2015 }
2016 if let Some(excluded) = exclude {
2018 if def == &excluded {
2019 debug!("Skipping excluded definition at line {}", def.line);
2020 return false;
2021 }
2022 }
2023 true
2024 })
2025 .max_by_key(|def| def.line)
2026 {
2027 info!(
2028 "Found fixture {} in same file at line {} (using last definition, excluding specified)",
2029 fixture_name, last_def.line
2030 );
2031 return Some(last_def.clone());
2032 }
2033
2034 let mut current_dir = file_path.parent()?;
2036
2037 debug!(
2038 "Searching for fixture {} in conftest.py files starting from {:?}",
2039 fixture_name, current_dir
2040 );
2041 loop {
2042 let conftest_path = current_dir.join("conftest.py");
2043 debug!(" Checking conftest.py at: {:?}", conftest_path);
2044
2045 for def in definitions.iter() {
2046 if def.file_path == conftest_path {
2047 if let Some(excluded) = exclude {
2049 if def == excluded {
2050 debug!("Skipping excluded definition at line {}", def.line);
2051 continue;
2052 }
2053 }
2054 info!(
2055 "Found fixture {} in conftest.py: {:?}",
2056 fixture_name, conftest_path
2057 );
2058 return Some(def.clone());
2059 }
2060 }
2061
2062 match current_dir.parent() {
2064 Some(parent) => current_dir = parent,
2065 None => break,
2066 }
2067 }
2068
2069 debug!(
2071 "No fixture {} found in conftest hierarchy (excluding specified), checking for third-party fixtures",
2072 fixture_name
2073 );
2074 for def in definitions.iter() {
2075 if let Some(excluded) = exclude {
2077 if def == excluded {
2078 continue;
2079 }
2080 }
2081 if def.file_path.to_string_lossy().contains("site-packages") {
2082 info!(
2083 "Found third-party fixture {} in site-packages: {:?}",
2084 fixture_name, def.file_path
2085 );
2086 return Some(def.clone());
2087 }
2088 }
2089
2090 warn!(
2092 "No fixture {} found following priority rules (excluding specified)",
2093 fixture_name
2094 );
2095 warn!(
2096 "Falling back to first definition by path (deterministic fallback for unrelated fixtures)"
2097 );
2098
2099 let mut defs: Vec<_> = definitions
2100 .iter()
2101 .filter(|def| {
2102 if let Some(excluded) = exclude {
2103 def != &excluded
2104 } else {
2105 true
2106 }
2107 })
2108 .cloned()
2109 .collect();
2110 defs.sort_by(|a, b| a.file_path.cmp(&b.file_path));
2111 defs.first().cloned()
2112 }
2113
2114 pub fn find_fixture_at_position(
2116 &self,
2117 file_path: &Path,
2118 line: u32,
2119 character: u32,
2120 ) -> Option<String> {
2121 let target_line = (line + 1) as usize; debug!(
2124 "find_fixture_at_position: file={:?}, line={}, char={}",
2125 file_path, target_line, character
2126 );
2127
2128 let content = self.get_file_content(file_path)?;
2131
2132 let line_content = content.lines().nth(target_line.saturating_sub(1))?;
2134 debug!("Line content: {}", line_content);
2135
2136 let word_at_cursor = self.extract_word_at_position(line_content, character as usize);
2138 debug!("Word at cursor: {:?}", word_at_cursor);
2139
2140 if let Some(usages) = self.usages.get(file_path) {
2143 for usage in usages.iter() {
2144 if usage.line == target_line {
2145 let cursor_pos = character as usize;
2147 if cursor_pos >= usage.start_char && cursor_pos < usage.end_char {
2148 debug!(
2149 "Cursor at {} is within usage range {}-{}: {}",
2150 cursor_pos, usage.start_char, usage.end_char, usage.name
2151 );
2152 info!("Found fixture usage at cursor position: {}", usage.name);
2153 return Some(usage.name.clone());
2154 }
2155 }
2156 }
2157 }
2158
2159 for entry in self.definitions.iter() {
2162 for def in entry.value().iter() {
2163 if def.file_path == file_path && def.line == target_line {
2164 if let Some(ref word) = word_at_cursor {
2166 if word == &def.name {
2167 info!(
2168 "Found fixture definition name at cursor position: {}",
2169 def.name
2170 );
2171 return Some(def.name.clone());
2172 }
2173 }
2174 }
2177 }
2178 }
2179
2180 debug!("No fixture found at cursor position");
2181 None
2182 }
2183
2184 pub fn extract_word_at_position(&self, line: &str, character: usize) -> Option<String> {
2185 let char_indices: Vec<(usize, char)> = line.char_indices().collect();
2187
2188 if character >= char_indices.len() {
2190 return None;
2191 }
2192
2193 let (_byte_pos, c) = char_indices[character];
2195
2196 if c.is_alphanumeric() || c == '_' {
2198 let mut start_idx = character;
2200 while start_idx > 0 {
2201 let (_, prev_c) = char_indices[start_idx - 1];
2202 if !prev_c.is_alphanumeric() && prev_c != '_' {
2203 break;
2204 }
2205 start_idx -= 1;
2206 }
2207
2208 let mut end_idx = character + 1;
2210 while end_idx < char_indices.len() {
2211 let (_, curr_c) = char_indices[end_idx];
2212 if !curr_c.is_alphanumeric() && curr_c != '_' {
2213 break;
2214 }
2215 end_idx += 1;
2216 }
2217
2218 let start_byte = char_indices[start_idx].0;
2220 let end_byte = if end_idx < char_indices.len() {
2221 char_indices[end_idx].0
2222 } else {
2223 line.len()
2224 };
2225
2226 return Some(line[start_byte..end_byte].to_string());
2227 }
2228
2229 None
2230 }
2231
2232 pub fn find_fixture_references(&self, fixture_name: &str) -> Vec<FixtureUsage> {
2234 info!("Finding all references for fixture: {}", fixture_name);
2235
2236 let mut all_references = Vec::new();
2237
2238 for entry in self.usages.iter() {
2240 let file_path = entry.key();
2241 let usages = entry.value();
2242
2243 for usage in usages.iter() {
2245 if usage.name == fixture_name {
2246 debug!(
2247 "Found reference to {} in {:?} at line {}",
2248 fixture_name, file_path, usage.line
2249 );
2250 all_references.push(usage.clone());
2251 }
2252 }
2253 }
2254
2255 info!(
2256 "Found {} total references for fixture: {}",
2257 all_references.len(),
2258 fixture_name
2259 );
2260 all_references
2261 }
2262
2263 pub fn find_references_for_definition(
2270 &self,
2271 definition: &FixtureDefinition,
2272 ) -> Vec<FixtureUsage> {
2273 info!(
2274 "Finding references for specific definition: {} at {:?}:{}",
2275 definition.name, definition.file_path, definition.line
2276 );
2277
2278 let mut matching_references = Vec::new();
2279
2280 for entry in self.usages.iter() {
2282 let file_path = entry.key();
2283 let usages = entry.value();
2284
2285 for usage in usages.iter() {
2286 if usage.name == definition.name {
2287 let fixture_def_at_line =
2290 self.get_fixture_definition_at_line(file_path, usage.line);
2291
2292 let resolved_def = if let Some(ref current_def) = fixture_def_at_line {
2293 if current_def.name == usage.name {
2294 debug!(
2296 "Usage at {:?}:{} is self-referencing, excluding definition at line {}",
2297 file_path, usage.line, current_def.line
2298 );
2299 self.find_closest_definition_excluding(
2300 file_path,
2301 &usage.name,
2302 Some(current_def),
2303 )
2304 } else {
2305 self.find_closest_definition(file_path, &usage.name)
2307 }
2308 } else {
2309 self.find_closest_definition(file_path, &usage.name)
2311 };
2312
2313 if let Some(resolved_def) = resolved_def {
2314 if resolved_def == *definition {
2315 debug!(
2316 "Usage at {:?}:{} resolves to our definition",
2317 file_path, usage.line
2318 );
2319 matching_references.push(usage.clone());
2320 } else {
2321 debug!(
2322 "Usage at {:?}:{} resolves to different definition at {:?}:{}",
2323 file_path, usage.line, resolved_def.file_path, resolved_def.line
2324 );
2325 }
2326 }
2327 }
2328 }
2329 }
2330
2331 info!(
2332 "Found {} references that resolve to this specific definition",
2333 matching_references.len()
2334 );
2335 matching_references
2336 }
2337
2338 pub fn get_undeclared_fixtures(&self, file_path: &Path) -> Vec<UndeclaredFixture> {
2340 self.undeclared_fixtures
2341 .get(file_path)
2342 .map(|entry| entry.value().clone())
2343 .unwrap_or_default()
2344 }
2345
2346 pub fn get_available_fixtures(&self, file_path: &Path) -> Vec<FixtureDefinition> {
2349 let mut available_fixtures = Vec::new();
2350 let mut seen_names = std::collections::HashSet::new();
2351
2352 for entry in self.definitions.iter() {
2354 let fixture_name = entry.key();
2355 for def in entry.value().iter() {
2356 if def.file_path == file_path && !seen_names.contains(fixture_name.as_str()) {
2357 available_fixtures.push(def.clone());
2358 seen_names.insert(fixture_name.clone());
2359 }
2360 }
2361 }
2362
2363 if let Some(mut current_dir) = file_path.parent() {
2365 loop {
2366 let conftest_path = current_dir.join("conftest.py");
2367
2368 for entry in self.definitions.iter() {
2369 let fixture_name = entry.key();
2370 for def in entry.value().iter() {
2371 if def.file_path == conftest_path
2372 && !seen_names.contains(fixture_name.as_str())
2373 {
2374 available_fixtures.push(def.clone());
2375 seen_names.insert(fixture_name.clone());
2376 }
2377 }
2378 }
2379
2380 match current_dir.parent() {
2382 Some(parent) => current_dir = parent,
2383 None => break,
2384 }
2385 }
2386 }
2387
2388 for entry in self.definitions.iter() {
2390 let fixture_name = entry.key();
2391 for def in entry.value().iter() {
2392 if def.file_path.to_string_lossy().contains("site-packages")
2393 && !seen_names.contains(fixture_name.as_str())
2394 {
2395 available_fixtures.push(def.clone());
2396 seen_names.insert(fixture_name.clone());
2397 }
2398 }
2399 }
2400
2401 available_fixtures.sort_by(|a, b| a.name.cmp(&b.name));
2403 available_fixtures
2404 }
2405
2406 pub fn is_inside_function(
2409 &self,
2410 file_path: &Path,
2411 line: u32,
2412 character: u32,
2413 ) -> Option<(String, bool, Vec<String>)> {
2414 let content = self.get_file_content(file_path)?;
2416
2417 let target_line = (line + 1) as usize; let parsed = parse(&content, Mode::Module, "").ok()?;
2421
2422 if let rustpython_parser::ast::Mod::Module(module) = parsed {
2423 return self.find_enclosing_function(
2424 &module.body,
2425 &content,
2426 target_line,
2427 character as usize,
2428 );
2429 }
2430
2431 None
2432 }
2433
2434 fn find_enclosing_function(
2435 &self,
2436 stmts: &[Stmt],
2437 content: &str,
2438 target_line: usize,
2439 _target_char: usize,
2440 ) -> Option<(String, bool, Vec<String>)> {
2441 for stmt in stmts {
2442 match stmt {
2443 Stmt::FunctionDef(func_def) => {
2444 let func_start_line = content[..func_def.range.start().to_usize()]
2445 .matches('\n')
2446 .count()
2447 + 1;
2448 let func_end_line = content[..func_def.range.end().to_usize()]
2449 .matches('\n')
2450 .count()
2451 + 1;
2452
2453 if target_line >= func_start_line && target_line <= func_end_line {
2455 let is_fixture = func_def
2456 .decorator_list
2457 .iter()
2458 .any(Self::is_fixture_decorator);
2459 let is_test = func_def.name.starts_with("test_");
2460
2461 if is_test || is_fixture {
2463 let params: Vec<String> = func_def
2464 .args
2465 .args
2466 .iter()
2467 .map(|arg| arg.def.arg.to_string())
2468 .collect();
2469
2470 return Some((func_def.name.to_string(), is_fixture, params));
2471 }
2472 }
2473 }
2474 Stmt::AsyncFunctionDef(func_def) => {
2475 let func_start_line = content[..func_def.range.start().to_usize()]
2476 .matches('\n')
2477 .count()
2478 + 1;
2479 let func_end_line = content[..func_def.range.end().to_usize()]
2480 .matches('\n')
2481 .count()
2482 + 1;
2483
2484 if target_line >= func_start_line && target_line <= func_end_line {
2485 let is_fixture = func_def
2486 .decorator_list
2487 .iter()
2488 .any(Self::is_fixture_decorator);
2489 let is_test = func_def.name.starts_with("test_");
2490
2491 if is_test || is_fixture {
2492 let params: Vec<String> = func_def
2493 .args
2494 .args
2495 .iter()
2496 .map(|arg| arg.def.arg.to_string())
2497 .collect();
2498
2499 return Some((func_def.name.to_string(), is_fixture, params));
2500 }
2501 }
2502 }
2503 _ => {}
2504 }
2505 }
2506
2507 None
2508 }
2509
2510 pub fn print_fixtures_tree(&self, root_path: &Path, skip_unused: bool, only_unused: bool) {
2513 let mut file_fixtures: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
2515
2516 for entry in self.definitions.iter() {
2517 let fixture_name = entry.key();
2518 let definitions = entry.value();
2519
2520 for def in definitions {
2521 file_fixtures
2522 .entry(def.file_path.clone())
2523 .or_default()
2524 .insert(fixture_name.clone());
2525 }
2526 }
2527
2528 let mut fixture_usage_counts: HashMap<String, usize> = HashMap::new();
2530 for entry in self.usages.iter() {
2531 let usages = entry.value();
2532 for usage in usages {
2533 *fixture_usage_counts.entry(usage.name.clone()).or_insert(0) += 1;
2534 }
2535 }
2536
2537 let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
2539 let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
2540
2541 for file_path in file_fixtures.keys() {
2542 all_paths.insert(file_path.clone());
2543
2544 let mut current = file_path.as_path();
2546 while let Some(parent) = current.parent() {
2547 if parent == root_path || parent.as_os_str().is_empty() {
2548 break;
2549 }
2550 all_paths.insert(parent.to_path_buf());
2551 current = parent;
2552 }
2553 }
2554
2555 for path in &all_paths {
2557 if let Some(parent) = path.parent() {
2558 if parent != root_path && !parent.as_os_str().is_empty() {
2559 tree.entry(parent.to_path_buf())
2560 .or_default()
2561 .push(path.clone());
2562 }
2563 }
2564 }
2565
2566 for children in tree.values_mut() {
2568 children.sort();
2569 }
2570
2571 println!("Fixtures tree for: {}", root_path.display());
2573 println!();
2574
2575 if file_fixtures.is_empty() {
2576 println!("No fixtures found in this directory.");
2577 return;
2578 }
2579
2580 let mut top_level: Vec<PathBuf> = all_paths
2582 .iter()
2583 .filter(|p| {
2584 if let Some(parent) = p.parent() {
2585 parent == root_path
2586 } else {
2587 false
2588 }
2589 })
2590 .cloned()
2591 .collect();
2592 top_level.sort();
2593
2594 for (i, path) in top_level.iter().enumerate() {
2595 let is_last = i == top_level.len() - 1;
2596 self.print_tree_node(
2597 path,
2598 &file_fixtures,
2599 &tree,
2600 "",
2601 is_last,
2602 true, &fixture_usage_counts,
2604 skip_unused,
2605 only_unused,
2606 );
2607 }
2608 }
2609
2610 #[allow(clippy::too_many_arguments)]
2611 #[allow(clippy::only_used_in_recursion)]
2612 fn print_tree_node(
2613 &self,
2614 path: &Path,
2615 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2616 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2617 prefix: &str,
2618 is_last: bool,
2619 is_root_level: bool,
2620 fixture_usage_counts: &HashMap<String, usize>,
2621 skip_unused: bool,
2622 only_unused: bool,
2623 ) {
2624 use colored::Colorize;
2625 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
2626
2627 let connector = if is_root_level {
2629 "" } else if is_last {
2631 "└── "
2632 } else {
2633 "├── "
2634 };
2635
2636 if path.is_file() {
2637 if let Some(fixtures) = file_fixtures.get(path) {
2639 let fixture_vec: Vec<_> = fixtures
2641 .iter()
2642 .filter(|fixture_name| {
2643 let usage_count = fixture_usage_counts
2644 .get(*fixture_name)
2645 .copied()
2646 .unwrap_or(0);
2647 if only_unused {
2648 usage_count == 0
2649 } else if skip_unused {
2650 usage_count > 0
2651 } else {
2652 true
2653 }
2654 })
2655 .collect();
2656
2657 if fixture_vec.is_empty() {
2659 return;
2660 }
2661
2662 let file_display = name.to_string().cyan().bold();
2663 println!(
2664 "{}{}{} ({} fixtures)",
2665 prefix,
2666 connector,
2667 file_display,
2668 fixture_vec.len()
2669 );
2670
2671 let new_prefix = if is_root_level {
2673 "".to_string()
2674 } else {
2675 format!("{}{}", prefix, if is_last { " " } else { "│ " })
2676 };
2677
2678 for (j, fixture_name) in fixture_vec.iter().enumerate() {
2679 let is_last_fixture = j == fixture_vec.len() - 1;
2680 let fixture_connector = if is_last_fixture {
2681 "└── "
2682 } else {
2683 "├── "
2684 };
2685
2686 let usage_count = fixture_usage_counts
2688 .get(*fixture_name)
2689 .copied()
2690 .unwrap_or(0);
2691
2692 let fixture_display = if usage_count == 0 {
2694 fixture_name.to_string().dimmed()
2696 } else {
2697 fixture_name.to_string().green()
2699 };
2700
2701 let usage_info = if usage_count == 0 {
2703 "unused".dimmed().to_string()
2704 } else if usage_count == 1 {
2705 format!("{}", "used 1 time".yellow())
2706 } else {
2707 format!("{}", format!("used {} times", usage_count).yellow())
2708 };
2709
2710 println!(
2711 "{}{}{} ({})",
2712 new_prefix, fixture_connector, fixture_display, usage_info
2713 );
2714 }
2715 } else {
2716 println!("{}{}{}", prefix, connector, name);
2717 }
2718 } else {
2719 if let Some(children) = tree.get(path) {
2721 let has_visible_children = children.iter().any(|child| {
2723 Self::has_visible_fixtures(
2724 child,
2725 file_fixtures,
2726 tree,
2727 fixture_usage_counts,
2728 skip_unused,
2729 only_unused,
2730 )
2731 });
2732
2733 if !has_visible_children {
2734 return;
2735 }
2736
2737 let dir_display = format!("{}/", name).blue().bold();
2738 println!("{}{}{}", prefix, connector, dir_display);
2739
2740 let new_prefix = if is_root_level {
2741 "".to_string()
2742 } else {
2743 format!("{}{}", prefix, if is_last { " " } else { "│ " })
2744 };
2745
2746 for (j, child) in children.iter().enumerate() {
2747 let is_last_child = j == children.len() - 1;
2748 self.print_tree_node(
2749 child,
2750 file_fixtures,
2751 tree,
2752 &new_prefix,
2753 is_last_child,
2754 false, fixture_usage_counts,
2756 skip_unused,
2757 only_unused,
2758 );
2759 }
2760 }
2761 }
2762 }
2763
2764 fn has_visible_fixtures(
2765 path: &Path,
2766 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
2767 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
2768 fixture_usage_counts: &HashMap<String, usize>,
2769 skip_unused: bool,
2770 only_unused: bool,
2771 ) -> bool {
2772 if path.is_file() {
2773 if let Some(fixtures) = file_fixtures.get(path) {
2775 return fixtures.iter().any(|fixture_name| {
2776 let usage_count = fixture_usage_counts.get(fixture_name).copied().unwrap_or(0);
2777 if only_unused {
2778 usage_count == 0
2779 } else if skip_unused {
2780 usage_count > 0
2781 } else {
2782 true
2783 }
2784 });
2785 }
2786 false
2787 } else {
2788 if let Some(children) = tree.get(path) {
2790 children.iter().any(|child| {
2791 Self::has_visible_fixtures(
2792 child,
2793 file_fixtures,
2794 tree,
2795 fixture_usage_counts,
2796 skip_unused,
2797 only_unused,
2798 )
2799 })
2800 } else {
2801 false
2802 }
2803 }
2804 }
2805}