pytest_language_server/fixtures/
cli.rs1use super::types::FixtureDefinition;
4use super::FixtureDatabase;
5use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
6use std::path::{Path, PathBuf};
7
8impl FixtureDatabase {
9 fn compute_definition_usage_counts(&self) -> HashMap<(PathBuf, String), usize> {
11 let mut counts: HashMap<(PathBuf, String), usize> = HashMap::new();
12
13 for entry in self.definitions.iter() {
15 let fixture_name = entry.key();
16 for def in entry.value().iter() {
17 counts.insert((def.file_path.clone(), fixture_name.clone()), 0);
18 }
19 }
20
21 let mut resolution_cache: HashMap<(PathBuf, String), Option<PathBuf>> = HashMap::new();
23
24 let mut fixture_def_lines: HashMap<PathBuf, HashMap<usize, FixtureDefinition>> =
26 HashMap::new();
27 for entry in self.definitions.iter() {
28 for def in entry.value().iter() {
29 fixture_def_lines
30 .entry(def.file_path.clone())
31 .or_default()
32 .insert(def.line, def.clone());
33 }
34 }
35
36 for entry in self.usages.iter() {
38 let file_path = entry.key();
39 let usages = entry.value();
40 let file_def_lines = fixture_def_lines.get(file_path);
41
42 for usage in usages.iter() {
43 let fixture_def_at_line = file_def_lines
44 .and_then(|lines| lines.get(&usage.line))
45 .cloned();
46
47 let is_self_referencing = fixture_def_at_line
48 .as_ref()
49 .is_some_and(|def| def.name == usage.name);
50
51 let resolved_def = if is_self_referencing {
52 self.find_closest_definition_excluding(
53 file_path,
54 &usage.name,
55 fixture_def_at_line.as_ref(),
56 )
57 } else {
58 let cache_key = (file_path.clone(), usage.name.clone());
59 if let Some(cached) = resolution_cache.get(&cache_key) {
60 cached.as_ref().and_then(|def_path| {
61 self.definitions.get(&usage.name).and_then(|defs| {
62 defs.iter().find(|d| &d.file_path == def_path).cloned()
63 })
64 })
65 } else {
66 let def = self.find_closest_definition(file_path, &usage.name);
67 resolution_cache
68 .insert(cache_key, def.as_ref().map(|d| d.file_path.clone()));
69 def
70 }
71 };
72
73 if let Some(def) = resolved_def {
74 let key = (def.file_path.clone(), usage.name.clone());
75 *counts.entry(key).or_insert(0) += 1;
76 }
77 }
78 }
79
80 counts
81 }
82
83 pub fn print_fixtures_tree(&self, root_path: &Path, skip_unused: bool, only_unused: bool) {
85 let mut file_fixtures: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
87
88 for entry in self.definitions.iter() {
89 let fixture_name = entry.key();
90 let definitions = entry.value();
91
92 for def in definitions {
93 file_fixtures
94 .entry(def.file_path.clone())
95 .or_default()
96 .insert(fixture_name.clone());
97 }
98 }
99
100 let mut definition_usage_counts = self.compute_definition_usage_counts();
101
102 let mut autouse_fixtures: HashSet<(PathBuf, String)> = HashSet::new();
103 for entry in self.definitions.iter() {
104 let fixture_name = entry.key();
105 for def in entry.value().iter() {
106 if def.autouse {
107 autouse_fixtures.insert((def.file_path.clone(), fixture_name.clone()));
108 }
109 }
110 }
111
112 let mut editable_dirs: HashSet<PathBuf> = HashSet::new();
115 {
116 let installs = self.editable_install_roots.lock().unwrap();
117 let workspace = self.workspace_root.lock().unwrap();
118 let mut remapped: Vec<(PathBuf, PathBuf)> = Vec::new();
119
120 for install in installs.iter() {
121 if let Some(ref ws) = *workspace {
125 if install.source_root.starts_with(ws) || ws.starts_with(&install.source_root) {
126 continue;
127 }
128 }
129
130 let keys_to_remap: Vec<PathBuf> = file_fixtures
131 .keys()
132 .filter(|p| p.starts_with(&install.source_root))
133 .cloned()
134 .collect();
135
136 for original_path in keys_to_remap {
137 if let Ok(relative) = original_path.strip_prefix(&install.source_root) {
138 let virtual_path = install.site_packages.join(relative);
139 let parts: Vec<&str> = install.raw_package_name.split('.').collect();
141 if !parts.is_empty() {
142 let mut label_path = install.site_packages.clone();
143 for part in &parts {
144 label_path = label_path.join(part.replace('-', "_"));
145 }
146 editable_dirs.insert(label_path);
147 }
148 remapped.push((original_path, virtual_path));
149 }
150 }
151 }
152
153 for (original, virtual_path) in &remapped {
154 if let Some(fixtures) = file_fixtures.remove(original) {
155 file_fixtures.insert(virtual_path.clone(), fixtures);
156 }
157 }
158
159 let mut remapped_counts: Vec<((PathBuf, String), (PathBuf, String))> = Vec::new();
161 for (original, virtual_path) in &remapped {
162 for key in definition_usage_counts.keys() {
163 if key.0 == *original {
164 remapped_counts.push((key.clone(), (virtual_path.clone(), key.1.clone())));
165 }
166 }
167 }
168 for (old_key, new_key) in remapped_counts {
169 if let Some(count) = definition_usage_counts.remove(&old_key) {
170 definition_usage_counts.insert(new_key, count);
171 }
172 }
173
174 let mut autouse_remapped: Vec<((PathBuf, String), (PathBuf, String))> = Vec::new();
176 for (original, virtual_path) in &remapped {
177 for key in autouse_fixtures.iter() {
178 if key.0 == *original {
179 autouse_remapped.push((key.clone(), (virtual_path.clone(), key.1.clone())));
180 }
181 }
182 }
183 for (old_key, new_key) in autouse_remapped {
184 autouse_fixtures.remove(&old_key);
185 autouse_fixtures.insert(new_key);
186 }
187 }
188
189 let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
191 let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
192
193 for file_path in file_fixtures.keys() {
194 all_paths.insert(file_path.clone());
195
196 let mut current = file_path.as_path();
197 while let Some(parent) = current.parent() {
198 if parent == root_path || parent.as_os_str().is_empty() {
199 break;
200 }
201 all_paths.insert(parent.to_path_buf());
202 current = parent;
203 }
204 }
205
206 for path in &all_paths {
207 if let Some(parent) = path.parent() {
208 if parent != root_path && !parent.as_os_str().is_empty() {
209 tree.entry(parent.to_path_buf())
210 .or_default()
211 .push(path.clone());
212 }
213 }
214 }
215
216 for children in tree.values_mut() {
217 children.sort();
218 }
219
220 println!("Fixtures tree for: {}", root_path.display());
221 println!();
222
223 if file_fixtures.is_empty() {
224 println!("No fixtures found in this directory.");
225 return;
226 }
227
228 let mut top_level: Vec<PathBuf> = all_paths
229 .iter()
230 .filter(|p| {
231 if let Some(parent) = p.parent() {
232 parent == root_path
233 } else {
234 false
235 }
236 })
237 .cloned()
238 .collect();
239 top_level.sort();
240
241 for (i, path) in top_level.iter().enumerate() {
242 let is_last = i == top_level.len() - 1;
243 self.print_tree_node(
244 path,
245 &file_fixtures,
246 &tree,
247 "",
248 is_last,
249 true,
250 &definition_usage_counts,
251 skip_unused,
252 only_unused,
253 &editable_dirs,
254 &autouse_fixtures,
255 );
256 }
257 }
258
259 #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
260 fn print_tree_node(
261 &self,
262 path: &Path,
263 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
264 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
265 prefix: &str,
266 is_last: bool,
267 is_root_level: bool,
268 definition_usage_counts: &HashMap<(PathBuf, String), usize>,
269 skip_unused: bool,
270 only_unused: bool,
271 editable_dirs: &HashSet<PathBuf>,
272 autouse_fixtures: &HashSet<(PathBuf, String)>,
273 ) {
274 use colored::Colorize;
275
276 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
277
278 let connector = if is_root_level {
279 ""
280 } else if is_last {
281 "└── "
282 } else {
283 "├── "
284 };
285
286 if file_fixtures.contains_key(path) {
287 if let Some(fixtures) = file_fixtures.get(path) {
288 let fixture_vec: Vec<_> = fixtures
289 .iter()
290 .filter(|fixture_name| {
291 let key = (path.to_path_buf(), (*fixture_name).clone());
292 let is_autouse = autouse_fixtures.contains(&key);
293 let usage_count = definition_usage_counts.get(&key).copied().unwrap_or(0);
294 if only_unused {
295 usage_count == 0 && !is_autouse
296 } else if skip_unused {
297 usage_count > 0 || is_autouse
298 } else {
299 true
300 }
301 })
302 .collect();
303
304 if fixture_vec.is_empty() {
305 return;
306 }
307
308 let file_display = name.to_string().cyan().bold();
309 println!(
310 "{}{}{} ({} fixtures)",
311 prefix,
312 connector,
313 file_display,
314 fixture_vec.len()
315 );
316
317 let new_prefix = if is_root_level {
318 "".to_string()
319 } else {
320 format!("{}{}", prefix, if is_last { " " } else { "│ " })
321 };
322
323 for (j, fixture_name) in fixture_vec.iter().enumerate() {
324 let is_last_fixture = j == fixture_vec.len() - 1;
325 let fixture_connector = if is_last_fixture {
326 "└── "
327 } else {
328 "├── "
329 };
330
331 let usage_count = definition_usage_counts
332 .get(&(path.to_path_buf(), (*fixture_name).clone()))
333 .copied()
334 .unwrap_or(0);
335
336 let is_autouse =
337 autouse_fixtures.contains(&(path.to_path_buf(), (*fixture_name).clone()));
338
339 let fixture_display = if is_autouse && usage_count == 0 {
340 fixture_name.to_string().cyan()
341 } else if usage_count == 0 {
342 fixture_name.to_string().dimmed()
343 } else {
344 fixture_name.to_string().green()
345 };
346
347 let usage_info = if is_autouse && usage_count == 0 {
348 "autouse=True".cyan().to_string()
349 } else if is_autouse {
350 format!(
351 "{}, {}",
352 if usage_count == 1 {
353 "used 1 time".yellow().to_string()
354 } else {
355 format!("used {} times", usage_count).yellow().to_string()
356 },
357 "autouse=True".cyan()
358 )
359 } else if usage_count == 0 {
360 "unused".dimmed().to_string()
361 } else if usage_count == 1 {
362 format!("{}", "used 1 time".yellow())
363 } else {
364 format!("{}", format!("used {} times", usage_count).yellow())
365 };
366
367 println!(
368 "{}{}{} ({})",
369 new_prefix, fixture_connector, fixture_display, usage_info
370 );
371 }
372 } else {
373 println!("{}{}{}", prefix, connector, name);
374 }
375 } else if let Some(children) = tree.get(path) {
376 let has_visible_children = children.iter().any(|child| {
377 Self::has_visible_fixtures(
378 child,
379 file_fixtures,
380 tree,
381 definition_usage_counts,
382 skip_unused,
383 only_unused,
384 autouse_fixtures,
385 )
386 });
387
388 if !has_visible_children {
389 return;
390 }
391
392 let dir_label = if editable_dirs.contains(path) {
393 format!("{}/ (editable install)", name)
394 } else {
395 format!("{}/", name)
396 };
397 let dir_display = dir_label.blue().bold();
398 println!("{}{}{}", prefix, connector, dir_display);
399
400 let new_prefix = if is_root_level {
401 "".to_string()
402 } else {
403 format!("{}{}", prefix, if is_last { " " } else { "│ " })
404 };
405
406 for (j, child) in children.iter().enumerate() {
407 let is_last_child = j == children.len() - 1;
408 self.print_tree_node(
409 child,
410 file_fixtures,
411 tree,
412 &new_prefix,
413 is_last_child,
414 false,
415 definition_usage_counts,
416 skip_unused,
417 only_unused,
418 editable_dirs,
419 autouse_fixtures,
420 );
421 }
422 }
423 }
424
425 fn has_visible_fixtures(
426 path: &Path,
427 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
428 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
429 definition_usage_counts: &HashMap<(PathBuf, String), usize>,
430 skip_unused: bool,
431 only_unused: bool,
432 autouse_fixtures: &HashSet<(PathBuf, String)>,
433 ) -> bool {
434 if file_fixtures.contains_key(path) {
435 if let Some(fixtures) = file_fixtures.get(path) {
436 return fixtures.iter().any(|fixture_name| {
437 let key = (path.to_path_buf(), fixture_name.clone());
438 let is_autouse = autouse_fixtures.contains(&key);
439 let usage_count = definition_usage_counts.get(&key).copied().unwrap_or(0);
440 if only_unused {
441 usage_count == 0 && !is_autouse
442 } else if skip_unused {
443 usage_count > 0 || is_autouse
444 } else {
445 true
446 }
447 });
448 }
449 false
450 } else if let Some(children) = tree.get(path) {
451 children.iter().any(|child| {
452 Self::has_visible_fixtures(
453 child,
454 file_fixtures,
455 tree,
456 definition_usage_counts,
457 skip_unused,
458 only_unused,
459 autouse_fixtures,
460 )
461 })
462 } else {
463 false
464 }
465 }
466
467 pub fn get_unused_fixtures(&self) -> Vec<(PathBuf, String)> {
471 let definition_usage_counts = self.compute_definition_usage_counts();
472 let mut unused: Vec<(PathBuf, String)> = Vec::new();
473
474 for entry in self.definitions.iter() {
475 let fixture_name = entry.key();
476 for def in entry.value().iter() {
477 if def.is_third_party {
479 continue;
480 }
481
482 if def.autouse {
484 continue;
485 }
486
487 let usage_count = definition_usage_counts
488 .get(&(def.file_path.clone(), fixture_name.clone()))
489 .copied()
490 .unwrap_or(0);
491
492 if usage_count == 0 {
493 unused.push((def.file_path.clone(), fixture_name.clone()));
494 }
495 }
496 }
497
498 unused.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
500 unused
501 }
502}