Skip to main content

cargo_quality/differ/display/
grouping.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use std::collections::{BTreeMap, HashSet};
5
6/// Groups and deduplicates import statements by root module.
7///
8/// Analyzes a list of import statements and performs intelligent grouping:
9/// - Removes duplicates
10/// - Groups imports by root module (std, syn, etc.)
11/// - Extracts common path segments
12/// - Formats as compact use statements
13///
14/// # Algorithm
15///
16/// 1. Deduplicate imports using HashSet
17/// 2. Parse each import into root module and path
18/// 3. Group by root module using BTreeMap (for sorted output)
19/// 4. Find common prefixes within each group
20/// 5. Format as `use root::path::{items}` or `use root::path::item`
21///
22/// # Arguments
23///
24/// * `imports` - Slice of import statements (e.g., "use std::fs::read;")
25///
26/// # Returns
27///
28/// Vector of grouped and formatted import statements
29///
30/// # Performance
31///
32/// - O(n log n) for deduplication and grouping
33/// - Pre-allocates result capacity based on unique count
34/// - Minimizes string allocations by reusing buffers
35///
36/// # Examples
37///
38/// ```
39/// use cargo_quality::differ::display::grouping::group_imports;
40///
41/// let imports = vec![
42///     "use std::fs::write;",
43///     "use std::fs::write;", // duplicate
44///     "use std::io::read;",
45/// ];
46///
47/// let grouped = group_imports(&imports);
48/// assert_eq!(grouped.len(), 1);
49/// assert!(grouped[0].contains("std::{"));
50/// ```
51///
52/// ```
53/// use cargo_quality::differ::display::grouping::group_imports;
54///
55/// let imports = vec!["use syn::visit::visit_file;", "use syn::visit::visit_expr;"];
56///
57/// let grouped = group_imports(&imports);
58/// assert_eq!(grouped.len(), 1);
59/// assert!(grouped[0].contains("syn::visit::{"));
60/// ```
61pub fn group_imports(imports: &[&str]) -> Vec<String> {
62    if imports.is_empty() {
63        return Vec::new();
64    }
65
66    let unique: HashSet<&str> = imports.iter().copied().collect();
67    let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
68
69    for import in unique {
70        let import_str = import
71            .trim_start_matches("use ")
72            .trim_end_matches(';')
73            .trim();
74
75        if let Some(double_colon_pos) = import_str.find("::") {
76            let root = import_str[..double_colon_pos].to_string();
77            let path = import_str[double_colon_pos + 2..].to_string();
78            grouped.entry(root).or_default().push(path);
79        } else {
80            grouped
81                .entry(import_str.to_string())
82                .or_default()
83                .push(String::new());
84        }
85    }
86
87    let mut result = Vec::with_capacity(grouped.len());
88
89    for (root, mut paths) in grouped {
90        if paths.len() == 1 && !paths[0].is_empty() {
91            result.push(format!("use {}::{};", root, paths[0]));
92        } else if paths.len() == 1 && paths[0].is_empty() {
93            result.push(format!("use {};", root));
94        } else {
95            paths.sort_unstable();
96
97            let common_prefix = find_common_prefix(&paths);
98
99            if !common_prefix.is_empty() {
100                let prefix_with_sep = if paths[0].starts_with(&format!("{}::", common_prefix)) {
101                    format!("{}::", common_prefix)
102                } else {
103                    common_prefix.clone()
104                };
105
106                let suffixes: Vec<String> = paths
107                    .iter()
108                    .map(|p| {
109                        p.strip_prefix(&prefix_with_sep)
110                            .unwrap_or(p.as_str())
111                            .to_string()
112                    })
113                    .collect();
114
115                if prefix_with_sep.ends_with("::") {
116                    result.push(format!(
117                        "use {}::{}::{{{}}};",
118                        root,
119                        common_prefix,
120                        suffixes.join(", ")
121                    ));
122                } else {
123                    result.push(format!("use {}::{{{}}};", root, suffixes.join(", ")));
124                }
125            } else {
126                result.push(format!("use {}::{{{}}};", root, paths.join(", ")));
127            }
128        }
129    }
130
131    result
132}
133
134/// Finds longest common prefix among import paths.
135///
136/// Uses component-wise comparison to find shared segments in import paths.
137/// Returns the longest common prefix that appears in all paths.
138///
139/// # Algorithm
140///
141/// 1. Split each path by "::" into components
142/// 2. Find minimum component count across all paths
143/// 3. Compare components at each position
144/// 4. Stop at first mismatch
145/// 5. Join common components back with "::"
146///
147/// # Arguments
148///
149/// * `paths` - Slice of import paths (without "use" keyword)
150///
151/// # Returns
152///
153/// Common prefix string, or empty if no common prefix
154///
155/// # Performance
156///
157/// - O(n × m) where n is path count, m is average component count
158/// - Early termination on first mismatch
159/// - Pre-allocates component vectors
160///
161/// # Examples
162///
163/// ```
164/// use cargo_quality::differ::display::grouping::find_common_prefix;
165///
166/// let paths = vec![
167///     "visit::visit_file".to_string(),
168///     "visit::visit_expr".to_string(),
169/// ];
170///
171/// let prefix = find_common_prefix(&paths);
172/// assert_eq!(prefix, "visit");
173/// ```
174///
175/// ```
176/// use cargo_quality::differ::display::grouping::find_common_prefix;
177///
178/// let paths = vec!["fs::read".to_string(), "io::write".to_string()];
179///
180/// let prefix = find_common_prefix(&paths);
181/// assert!(prefix.is_empty());
182/// ```
183pub fn find_common_prefix(paths: &[String]) -> String {
184    if paths.is_empty() {
185        return String::new();
186    }
187
188    if paths.len() == 1 {
189        return String::new();
190    }
191
192    let parts: Vec<Vec<&str>> = paths.iter().map(|p| p.split("::").collect()).collect();
193
194    let min_len = parts.iter().map(|p| p.len()).min().unwrap_or(0);
195
196    let mut common = Vec::with_capacity(min_len);
197
198    for i in 0..min_len {
199        let first = parts[0][i];
200        if parts.iter().all(|p| p[i] == first) {
201            common.push(first);
202        } else {
203            break;
204        }
205    }
206
207    if !common.is_empty() {
208        common.join("::")
209    } else {
210        String::new()
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_group_imports_deduplication() {
220        let imports = vec!["use std::fs::write;", "use std::fs::write;"];
221        let result = group_imports(&imports);
222        assert_eq!(result.len(), 1);
223    }
224
225    #[test]
226    fn test_group_imports_same_root() {
227        let imports = vec!["use std::fs::write;", "use std::io::read;"];
228        let result = group_imports(&imports);
229        assert_eq!(result.len(), 1);
230        assert!(result[0].contains("std::{"));
231    }
232
233    #[test]
234    fn test_group_imports_common_path() {
235        let imports = vec!["use syn::visit::visit_file;", "use syn::visit::visit_expr;"];
236        let result = group_imports(&imports);
237        assert_eq!(result.len(), 1);
238        assert!(result[0].contains("syn::visit::{"));
239    }
240
241    #[test]
242    fn test_group_imports_empty() {
243        let imports: Vec<&str> = vec![];
244        let result = group_imports(&imports);
245        assert!(result.is_empty());
246    }
247
248    #[test]
249    fn test_find_common_prefix_same() {
250        let paths = vec![
251            "visit::visit_file".to_string(),
252            "visit::visit_expr".to_string(),
253        ];
254        let prefix = find_common_prefix(&paths);
255        assert_eq!(prefix, "visit");
256    }
257
258    #[test]
259    fn test_find_common_prefix_different() {
260        let paths = vec!["fs::read".to_string(), "io::write".to_string()];
261        let prefix = find_common_prefix(&paths);
262        assert!(prefix.is_empty());
263    }
264
265    #[test]
266    fn test_find_common_prefix_empty() {
267        let paths: Vec<String> = vec![];
268        let prefix = find_common_prefix(&paths);
269        assert!(prefix.is_empty());
270    }
271
272    #[test]
273    fn test_find_common_prefix_single() {
274        let paths = vec!["test::path".to_string()];
275        let prefix = find_common_prefix(&paths);
276        assert!(prefix.is_empty());
277    }
278
279    #[test]
280    fn test_group_imports_multiple_roots() {
281        let imports = vec!["use std::fs::write;", "use syn::visit::visit_file;"];
282        let result = group_imports(&imports);
283        assert_eq!(result.len(), 2);
284    }
285
286    #[test]
287    fn test_group_imports_single_item() {
288        let imports = vec!["use std::fs::write;"];
289        let result = group_imports(&imports);
290        assert_eq!(result.len(), 1);
291        assert_eq!(result[0], "use std::fs::write;");
292    }
293
294    #[test]
295    fn test_find_common_prefix_partial() {
296        let paths = vec![
297            "a::b::c".to_string(),
298            "a::b::d".to_string(),
299            "a::b::e".to_string(),
300        ];
301        let prefix = find_common_prefix(&paths);
302        assert_eq!(prefix, "a::b");
303    }
304}