Skip to main content

pytest_language_server/fixtures/
cli.rs

1//! CLI-related methods for fixture display and tree printing.
2
3use super::types::FixtureDefinition;
4use super::FixtureDatabase;
5use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
6use std::path::{Path, PathBuf};
7
8impl FixtureDatabase {
9    /// Compute usage counts for all fixture definitions efficiently.
10    fn compute_definition_usage_counts(&self) -> HashMap<(PathBuf, String), usize> {
11        let mut counts: HashMap<(PathBuf, String), usize> = HashMap::new();
12
13        // Initialize all definitions with 0 count
14        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        // Cache for resolved definitions
22        let mut resolution_cache: HashMap<(PathBuf, String), Option<PathBuf>> = HashMap::new();
23
24        // Pre-compute fixture definition lines per file
25        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        // Iterate all usages once
37        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    /// Print fixtures as a tree structure
84    pub fn print_fixtures_tree(&self, root_path: &Path, skip_unused: bool, only_unused: bool) {
85        // Collect all files that define fixtures
86        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        // Remap editable install paths to virtual site-packages paths for display.
103        // Only remap files that are outside the workspace (third-party editable installs).
104        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                // Skip editable installs that overlap with the workspace:
112                // - source_root is inside workspace (in-workspace editable)
113                // - workspace is inside source_root (project installed editable in its own venv)
114                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                        // Build label path from raw package name (dot-separated for namespace packages)
130                        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            // Remap usage count keys to match virtual paths
150            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        // Build a tree structure from paths
166        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    /// Get all unused fixtures (fixtures with zero usages).
423    /// Returns a vector of (file_path, fixture_name) tuples sorted by path then name.
424    /// Excludes third-party fixtures from site-packages.
425    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                // Skip third-party fixtures
433                if def.is_third_party {
434                    continue;
435                }
436
437                // Skip autouse fixtures (they're used implicitly)
438                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        // Sort by file path, then by fixture name for deterministic output
454        unused.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
455        unused
456    }
457}