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        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        // Remap editable install paths to virtual site-packages paths for display.
113        // Only remap files that are outside the workspace (third-party editable installs).
114        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                // Skip editable installs that overlap with the workspace:
122                // - source_root is inside workspace (in-workspace editable)
123                // - workspace is inside source_root (project installed editable in its own venv)
124                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                        // Build label path from raw package name (dot-separated for namespace packages)
140                        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            // Remap usage count keys to match virtual paths
160            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            // Remap autouse fixture keys to match virtual paths
175            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        // Build a tree structure from paths
190        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    /// Get all unused fixtures (fixtures with zero usages).
468    /// Returns a vector of (file_path, fixture_name) tuples sorted by path then name.
469    /// Excludes third-party fixtures from site-packages.
470    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                // Skip third-party fixtures
478                if def.is_third_party {
479                    continue;
480                }
481
482                // Skip autouse fixtures (they're used implicitly)
483                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        // Sort by file path, then by fixture name for deterministic output
499        unused.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
500        unused
501    }
502}