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};
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 definition_usage_counts = self.compute_definition_usage_counts();
101
102        // Build a tree structure from paths
103        let mut tree: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
104        let mut all_paths: BTreeSet<PathBuf> = BTreeSet::new();
105
106        for file_path in file_fixtures.keys() {
107            all_paths.insert(file_path.clone());
108
109            let mut current = file_path.as_path();
110            while let Some(parent) = current.parent() {
111                if parent == root_path || parent.as_os_str().is_empty() {
112                    break;
113                }
114                all_paths.insert(parent.to_path_buf());
115                current = parent;
116            }
117        }
118
119        for path in &all_paths {
120            if let Some(parent) = path.parent() {
121                if parent != root_path && !parent.as_os_str().is_empty() {
122                    tree.entry(parent.to_path_buf())
123                        .or_default()
124                        .push(path.clone());
125                }
126            }
127        }
128
129        for children in tree.values_mut() {
130            children.sort();
131        }
132
133        println!("Fixtures tree for: {}", root_path.display());
134        println!();
135
136        if file_fixtures.is_empty() {
137            println!("No fixtures found in this directory.");
138            return;
139        }
140
141        let mut top_level: Vec<PathBuf> = all_paths
142            .iter()
143            .filter(|p| {
144                if let Some(parent) = p.parent() {
145                    parent == root_path
146                } else {
147                    false
148                }
149            })
150            .cloned()
151            .collect();
152        top_level.sort();
153
154        for (i, path) in top_level.iter().enumerate() {
155            let is_last = i == top_level.len() - 1;
156            self.print_tree_node(
157                path,
158                &file_fixtures,
159                &tree,
160                "",
161                is_last,
162                true,
163                &definition_usage_counts,
164                skip_unused,
165                only_unused,
166            );
167        }
168    }
169
170    #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
171    fn print_tree_node(
172        &self,
173        path: &Path,
174        file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
175        tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
176        prefix: &str,
177        is_last: bool,
178        is_root_level: bool,
179        definition_usage_counts: &HashMap<(PathBuf, String), usize>,
180        skip_unused: bool,
181        only_unused: bool,
182    ) {
183        use colored::Colorize;
184
185        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
186
187        let connector = if is_root_level {
188            ""
189        } else if is_last {
190            "└── "
191        } else {
192            "├── "
193        };
194
195        if path.is_file() {
196            if let Some(fixtures) = file_fixtures.get(path) {
197                let fixture_vec: Vec<_> = fixtures
198                    .iter()
199                    .filter(|fixture_name| {
200                        let usage_count = definition_usage_counts
201                            .get(&(path.to_path_buf(), (*fixture_name).clone()))
202                            .copied()
203                            .unwrap_or(0);
204                        if only_unused {
205                            usage_count == 0
206                        } else if skip_unused {
207                            usage_count > 0
208                        } else {
209                            true
210                        }
211                    })
212                    .collect();
213
214                if fixture_vec.is_empty() {
215                    return;
216                }
217
218                let file_display = name.to_string().cyan().bold();
219                println!(
220                    "{}{}{} ({} fixtures)",
221                    prefix,
222                    connector,
223                    file_display,
224                    fixture_vec.len()
225                );
226
227                let new_prefix = if is_root_level {
228                    "".to_string()
229                } else {
230                    format!("{}{}", prefix, if is_last { "    " } else { "│   " })
231                };
232
233                for (j, fixture_name) in fixture_vec.iter().enumerate() {
234                    let is_last_fixture = j == fixture_vec.len() - 1;
235                    let fixture_connector = if is_last_fixture {
236                        "└── "
237                    } else {
238                        "├── "
239                    };
240
241                    let usage_count = definition_usage_counts
242                        .get(&(path.to_path_buf(), (*fixture_name).clone()))
243                        .copied()
244                        .unwrap_or(0);
245
246                    let fixture_display = if usage_count == 0 {
247                        fixture_name.to_string().dimmed()
248                    } else {
249                        fixture_name.to_string().green()
250                    };
251
252                    let usage_info = if usage_count == 0 {
253                        "unused".dimmed().to_string()
254                    } else if usage_count == 1 {
255                        format!("{}", "used 1 time".yellow())
256                    } else {
257                        format!("{}", format!("used {} times", usage_count).yellow())
258                    };
259
260                    println!(
261                        "{}{}{} ({})",
262                        new_prefix, fixture_connector, fixture_display, usage_info
263                    );
264                }
265            } else {
266                println!("{}{}{}", prefix, connector, name);
267            }
268        } else if let Some(children) = tree.get(path) {
269            let has_visible_children = children.iter().any(|child| {
270                Self::has_visible_fixtures(
271                    child,
272                    file_fixtures,
273                    tree,
274                    definition_usage_counts,
275                    skip_unused,
276                    only_unused,
277                )
278            });
279
280            if !has_visible_children {
281                return;
282            }
283
284            let dir_display = format!("{}/", name).blue().bold();
285            println!("{}{}{}", prefix, connector, dir_display);
286
287            let new_prefix = if is_root_level {
288                "".to_string()
289            } else {
290                format!("{}{}", prefix, if is_last { "    " } else { "│   " })
291            };
292
293            for (j, child) in children.iter().enumerate() {
294                let is_last_child = j == children.len() - 1;
295                self.print_tree_node(
296                    child,
297                    file_fixtures,
298                    tree,
299                    &new_prefix,
300                    is_last_child,
301                    false,
302                    definition_usage_counts,
303                    skip_unused,
304                    only_unused,
305                );
306            }
307        }
308    }
309
310    fn has_visible_fixtures(
311        path: &Path,
312        file_fixtures: &BTreeMap<PathBuf, BTreeSet<String>>,
313        tree: &BTreeMap<PathBuf, Vec<PathBuf>>,
314        definition_usage_counts: &HashMap<(PathBuf, String), usize>,
315        skip_unused: bool,
316        only_unused: bool,
317    ) -> bool {
318        if path.is_file() {
319            if let Some(fixtures) = file_fixtures.get(path) {
320                return fixtures.iter().any(|fixture_name| {
321                    let usage_count = definition_usage_counts
322                        .get(&(path.to_path_buf(), fixture_name.clone()))
323                        .copied()
324                        .unwrap_or(0);
325                    if only_unused {
326                        usage_count == 0
327                    } else if skip_unused {
328                        usage_count > 0
329                    } else {
330                        true
331                    }
332                });
333            }
334            false
335        } else if let Some(children) = tree.get(path) {
336            children.iter().any(|child| {
337                Self::has_visible_fixtures(
338                    child,
339                    file_fixtures,
340                    tree,
341                    definition_usage_counts,
342                    skip_unused,
343                    only_unused,
344                )
345            })
346        } else {
347            false
348        }
349    }
350
351    /// Get all unused fixtures (fixtures with zero usages).
352    /// Returns a vector of (file_path, fixture_name) tuples sorted by path then name.
353    /// Excludes third-party fixtures from site-packages.
354    pub fn get_unused_fixtures(&self) -> Vec<(PathBuf, String)> {
355        let definition_usage_counts = self.compute_definition_usage_counts();
356        let mut unused: Vec<(PathBuf, String)> = Vec::new();
357
358        for entry in self.definitions.iter() {
359            let fixture_name = entry.key();
360            for def in entry.value().iter() {
361                // Skip third-party fixtures
362                if def.is_third_party {
363                    continue;
364                }
365
366                let usage_count = definition_usage_counts
367                    .get(&(def.file_path.clone(), fixture_name.clone()))
368                    .copied()
369                    .unwrap_or(0);
370
371                if usage_count == 0 {
372                    unused.push((def.file_path.clone(), fixture_name.clone()));
373                }
374            }
375        }
376
377        // Sort by file path, then by fixture name for deterministic output
378        unused.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
379        unused
380    }
381}