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 editable_dirs: HashSet<PathBuf> = HashSet::new();
105 {
106 let installs = self.editable_install_roots.lock().unwrap();
107 let workspace = self.workspace_root.lock().unwrap();
108 let mut remapped: Vec<(PathBuf, PathBuf)> = Vec::new();
109
110 for install in installs.iter() {
111 if let Some(ref ws) = *workspace {
115 if install.source_root.starts_with(ws) || ws.starts_with(&install.source_root) {
116 continue;
117 }
118 }
119
120 let keys_to_remap: Vec<PathBuf> = file_fixtures
121 .keys()
122 .filter(|p| p.starts_with(&install.source_root))
123 .cloned()
124 .collect();
125
126 for original_path in keys_to_remap {
127 if let Ok(relative) = original_path.strip_prefix(&install.source_root) {
128 let virtual_path = install.site_packages.join(relative);
129 let parts: Vec<&str> = install.raw_package_name.split('.').collect();
131 if !parts.is_empty() {
132 let mut label_path = install.site_packages.clone();
133 for part in &parts {
134 label_path = label_path.join(part.replace('-', "_"));
135 }
136 editable_dirs.insert(label_path);
137 }
138 remapped.push((original_path, virtual_path));
139 }
140 }
141 }
142
143 for (original, virtual_path) in &remapped {
144 if let Some(fixtures) = file_fixtures.remove(original) {
145 file_fixtures.insert(virtual_path.clone(), fixtures);
146 }
147 }
148
149 let mut remapped_counts: Vec<((PathBuf, String), (PathBuf, String))> = Vec::new();
151 for (original, virtual_path) in &remapped {
152 for key in definition_usage_counts.keys() {
153 if key.0 == *original {
154 remapped_counts.push((key.clone(), (virtual_path.clone(), key.1.clone())));
155 }
156 }
157 }
158 for (old_key, new_key) in remapped_counts {
159 if let Some(count) = definition_usage_counts.remove(&old_key) {
160 definition_usage_counts.insert(new_key, count);
161 }
162 }
163 }
164
165 let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
167 let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
168
169 for file_path in file_fixtures.keys() {
170 all_paths.insert(file_path.clone());
171
172 let mut current = file_path.as_path();
173 while let Some(parent) = current.parent() {
174 if parent == root_path || parent.as_os_str().is_empty() {
175 break;
176 }
177 all_paths.insert(parent.to_path_buf());
178 current = parent;
179 }
180 }
181
182 for path in &all_paths {
183 if let Some(parent) = path.parent() {
184 if parent != root_path && !parent.as_os_str().is_empty() {
185 tree.entry(parent.to_path_buf())
186 .or_default()
187 .push(path.clone());
188 }
189 }
190 }
191
192 for children in tree.values_mut() {
193 children.sort();
194 }
195
196 println!("Fixtures tree for: {}", root_path.display());
197 println!();
198
199 if file_fixtures.is_empty() {
200 println!("No fixtures found in this directory.");
201 return;
202 }
203
204 let mut top_level: Vec<PathBuf> = all_paths
205 .iter()
206 .filter(|p| {
207 if let Some(parent) = p.parent() {
208 parent == root_path
209 } else {
210 false
211 }
212 })
213 .cloned()
214 .collect();
215 top_level.sort();
216
217 for (i, path) in top_level.iter().enumerate() {
218 let is_last = i == top_level.len() - 1;
219 self.print_tree_node(
220 path,
221 &file_fixtures,
222 &tree,
223 "",
224 is_last,
225 true,
226 &definition_usage_counts,
227 skip_unused,
228 only_unused,
229 &editable_dirs,
230 );
231 }
232 }
233
234 #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
235 fn print_tree_node(
236 &self,
237 path: &Path,
238 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
239 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
240 prefix: &str,
241 is_last: bool,
242 is_root_level: bool,
243 definition_usage_counts: &HashMap<(PathBuf, String), usize>,
244 skip_unused: bool,
245 only_unused: bool,
246 editable_dirs: &HashSet<PathBuf>,
247 ) {
248 use colored::Colorize;
249
250 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
251
252 let connector = if is_root_level {
253 ""
254 } else if is_last {
255 "└── "
256 } else {
257 "├── "
258 };
259
260 if path.is_file() {
261 if let Some(fixtures) = file_fixtures.get(path) {
262 let fixture_vec: Vec<_> = fixtures
263 .iter()
264 .filter(|fixture_name| {
265 let usage_count = definition_usage_counts
266 .get(&(path.to_path_buf(), (*fixture_name).clone()))
267 .copied()
268 .unwrap_or(0);
269 if only_unused {
270 usage_count == 0
271 } else if skip_unused {
272 usage_count > 0
273 } else {
274 true
275 }
276 })
277 .collect();
278
279 if fixture_vec.is_empty() {
280 return;
281 }
282
283 let file_display = name.to_string().cyan().bold();
284 println!(
285 "{}{}{} ({} fixtures)",
286 prefix,
287 connector,
288 file_display,
289 fixture_vec.len()
290 );
291
292 let new_prefix = if is_root_level {
293 "".to_string()
294 } else {
295 format!("{}{}", prefix, if is_last { " " } else { "│ " })
296 };
297
298 for (j, fixture_name) in fixture_vec.iter().enumerate() {
299 let is_last_fixture = j == fixture_vec.len() - 1;
300 let fixture_connector = if is_last_fixture {
301 "└── "
302 } else {
303 "├── "
304 };
305
306 let usage_count = definition_usage_counts
307 .get(&(path.to_path_buf(), (*fixture_name).clone()))
308 .copied()
309 .unwrap_or(0);
310
311 let fixture_display = if usage_count == 0 {
312 fixture_name.to_string().dimmed()
313 } else {
314 fixture_name.to_string().green()
315 };
316
317 let usage_info = if usage_count == 0 {
318 "unused".dimmed().to_string()
319 } else if usage_count == 1 {
320 format!("{}", "used 1 time".yellow())
321 } else {
322 format!("{}", format!("used {} times", usage_count).yellow())
323 };
324
325 println!(
326 "{}{}{} ({})",
327 new_prefix, fixture_connector, fixture_display, usage_info
328 );
329 }
330 } else {
331 println!("{}{}{}", prefix, connector, name);
332 }
333 } else if let Some(children) = tree.get(path) {
334 let has_visible_children = children.iter().any(|child| {
335 Self::has_visible_fixtures(
336 child,
337 file_fixtures,
338 tree,
339 definition_usage_counts,
340 skip_unused,
341 only_unused,
342 )
343 });
344
345 if !has_visible_children {
346 return;
347 }
348
349 let dir_label = if editable_dirs.contains(path) {
350 format!("{}/ (editable install)", name)
351 } else {
352 format!("{}/", name)
353 };
354 let dir_display = dir_label.blue().bold();
355 println!("{}{}{}", prefix, connector, dir_display);
356
357 let new_prefix = if is_root_level {
358 "".to_string()
359 } else {
360 format!("{}{}", prefix, if is_last { " " } else { "│ " })
361 };
362
363 for (j, child) in children.iter().enumerate() {
364 let is_last_child = j == children.len() - 1;
365 self.print_tree_node(
366 child,
367 file_fixtures,
368 tree,
369 &new_prefix,
370 is_last_child,
371 false,
372 definition_usage_counts,
373 skip_unused,
374 only_unused,
375 editable_dirs,
376 );
377 }
378 }
379 }
380
381 fn has_visible_fixtures(
382 path: &Path,
383 file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
384 tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
385 definition_usage_counts: &HashMap<(PathBuf, String), usize>,
386 skip_unused: bool,
387 only_unused: bool,
388 ) -> bool {
389 if path.is_file() {
390 if let Some(fixtures) = file_fixtures.get(path) {
391 return fixtures.iter().any(|fixture_name| {
392 let usage_count = definition_usage_counts
393 .get(&(path.to_path_buf(), fixture_name.clone()))
394 .copied()
395 .unwrap_or(0);
396 if only_unused {
397 usage_count == 0
398 } else if skip_unused {
399 usage_count > 0
400 } else {
401 true
402 }
403 });
404 }
405 false
406 } else if let Some(children) = tree.get(path) {
407 children.iter().any(|child| {
408 Self::has_visible_fixtures(
409 child,
410 file_fixtures,
411 tree,
412 definition_usage_counts,
413 skip_unused,
414 only_unused,
415 )
416 })
417 } else {
418 false
419 }
420 }
421
422 pub fn get_unused_fixtures(&self) -> Vec<(PathBuf, String)> {
426 let definition_usage_counts = self.compute_definition_usage_counts();
427 let mut unused: Vec<(PathBuf, String)> = Vec::new();
428
429 for entry in self.definitions.iter() {
430 let fixture_name = entry.key();
431 for def in entry.value().iter() {
432 if def.is_third_party {
434 continue;
435 }
436
437 if def.autouse {
439 continue;
440 }
441
442 let usage_count = definition_usage_counts
443 .get(&(def.file_path.clone(), fixture_name.clone()))
444 .copied()
445 .unwrap_or(0);
446
447 if usage_count == 0 {
448 unused.push((def.file_path.clone(), fixture_name.clone()));
449 }
450 }
451 }
452
453 unused.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
455 unused
456 }
457}