pytest_language_server/fixtures/
cli.rs1use super::types::FixtureDefinition;
4use super::FixtureDatabase;
5use std::collections::{BTreeMap, BTreeSet, HashMap};
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 definition_usage_counts = self.compute_definition_usage_counts();
101
102 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 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 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 unused.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
379 unused
380 }
381}