Skip to main content

cargo_quality/analyzers/
path_import.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Path import analyzer for detecting inline path usage.
5//!
6//! This analyzer identifies module paths with `::` that should be moved to
7//! import statements. It distinguishes between:
8//! - Free functions from modules (should be imported)
9//! - Associated functions on types (should NOT be imported)
10//! - Enum variants (should NOT be imported)
11//! - Associated constants (should NOT be imported)
12
13use std::collections::{HashMap, HashSet};
14
15use masterror::AppResult;
16use syn::{
17    ExprPath, File, Item, ItemUse, Path,
18    punctuated::Punctuated,
19    spanned::Spanned,
20    visit::Visit,
21    visit_mut::{self, VisitMut}
22};
23
24use crate::analyzer::{AnalysisResult, Analyzer, Fix, Issue};
25
26/// Analyzer for detecting path separators that should be imports.
27///
28/// Detects module-level function calls using `::` syntax that should be
29/// converted to proper import statements for cleaner, more idiomatic code.
30///
31/// # Examples
32///
33/// Detects this pattern:
34/// ```ignore
35/// let content = std::fs::read_to_string("file.txt");
36/// ```
37///
38/// Suggests:
39/// ```ignore
40/// use std::fs::read_to_string;
41/// let content = read_to_string("file.txt");
42/// ```
43pub struct PathImportAnalyzer;
44
45impl PathImportAnalyzer {
46    /// Create new path import analyzer instance.
47    #[inline]
48    pub fn new() -> Self {
49        Self
50    }
51
52    /// Determine if path should be extracted to import statement.
53    ///
54    /// Analyzes path segments to distinguish module paths from type paths.
55    ///
56    /// # Arguments
57    ///
58    /// * `path` - Syntax path to analyze
59    ///
60    /// # Returns
61    ///
62    /// `true` if path represents free function that should be imported
63    fn should_extract_to_import(path: &Path) -> bool {
64        if path.segments.len() < 2 {
65            return false;
66        }
67
68        let first_segment = match path.segments.first() {
69            Some(seg) => seg,
70            None => return false
71        };
72
73        let first_name = first_segment.ident.to_string();
74
75        let first_char = match first_name.chars().next() {
76            Some(c) => c,
77            None => return false
78        };
79
80        if first_char.is_uppercase() {
81            return false;
82        }
83
84        let last_segment = match path.segments.last() {
85            Some(seg) => seg,
86            None => return false
87        };
88
89        let last_name = last_segment.ident.to_string();
90
91        if Self::is_screaming_snake_case(&last_name) {
92            return false;
93        }
94
95        let last_first_char = match last_name.chars().next() {
96            Some(c) => c,
97            None => return false
98        };
99
100        if last_first_char.is_uppercase() {
101            return false;
102        }
103
104        if path.segments.len() >= 2 {
105            let second_to_last = path.segments.iter().rev().nth(1);
106            if let Some(seg) = second_to_last {
107                let seg_name = seg.ident.to_string();
108                if let Some(c) = seg_name.chars().next()
109                    && c.is_uppercase()
110                {
111                    return false;
112                }
113            }
114        }
115
116        if Self::is_stdlib_root(&first_name) {
117            return true;
118        }
119
120        if path.segments.len() >= 3 && first_char.is_lowercase() {
121            return true;
122        }
123
124        false
125    }
126
127    /// Check if identifier is SCREAMING_SNAKE_CASE constant.
128    ///
129    /// # Arguments
130    ///
131    /// * `s` - Identifier string to check
132    ///
133    /// # Returns
134    ///
135    /// `true` if all characters are uppercase, underscore, or numeric
136    fn is_screaming_snake_case(s: &str) -> bool {
137        s.chars()
138            .all(|c| c.is_uppercase() || c == '_' || c.is_numeric())
139    }
140
141    /// Check if name is standard library root module.
142    ///
143    /// # Arguments
144    ///
145    /// * `name` - Module name to check
146    ///
147    /// # Returns
148    ///
149    /// `true` if name is `std`, `core`, or `alloc`
150    fn is_stdlib_root(name: &str) -> bool {
151        matches!(name, "std" | "core" | "alloc")
152    }
153}
154
155impl Analyzer for PathImportAnalyzer {
156    fn name(&self) -> &'static str {
157        "path_import"
158    }
159
160    fn analyze(&self, ast: &File, _content: &str) -> AppResult<AnalysisResult> {
161        let mut visitor = PathVisitor {
162            issues: Vec::new()
163        };
164        visitor.visit_file(ast);
165
166        let fixable_count = visitor.issues.len();
167
168        Ok(AnalysisResult {
169            issues: visitor.issues,
170            fixable_count
171        })
172    }
173
174    fn fix(&self, ast: &mut File) -> AppResult<usize> {
175        let mut collector = PathCollector {
176            paths: HashMap::new()
177        };
178        collector.visit_file(ast);
179
180        let blocked: HashSet<String> = collector
181            .paths
182            .into_iter()
183            .filter(|(_, sources)| sources.len() > 1)
184            .map(|(ident, _)| ident)
185            .collect();
186
187        let mut fixer = PathFixer {
188            fixed_count: 0,
189            imports: Vec::new(),
190            seen: HashSet::new(),
191            blocked
192        };
193        fixer.visit_file_mut(ast);
194
195        if !fixer.imports.is_empty() {
196            let mut items = std::mem::take(&mut fixer.imports);
197            items.append(&mut ast.items);
198            ast.items = items;
199        }
200
201        Ok(fixer.fixed_count)
202    }
203}
204
205/// Full colon-joined path string of an expression path.
206///
207/// # Arguments
208///
209/// * `path` - Path to render
210///
211/// # Returns
212///
213/// Segments joined with `::`
214fn path_to_string(path: &Path) -> String {
215    path.segments
216        .iter()
217        .map(|segment| segment.ident.to_string())
218        .collect::<Vec<_>>()
219        .join("::")
220}
221
222/// Collects the final identifier of each extractable path and its source paths.
223///
224/// Used to detect short-name collisions: an identifier reachable from more than
225/// one distinct full path cannot be safely rewritten to an import.
226struct PathCollector {
227    paths: HashMap<String, HashSet<String>>
228}
229
230impl<'ast> Visit<'ast> for PathCollector {
231    fn visit_expr_path(&mut self, node: &'ast ExprPath) {
232        if node.qself.is_none()
233            && PathImportAnalyzer::should_extract_to_import(&node.path)
234            && let Some(last) = node.path.segments.last()
235        {
236            let ident = last.ident.to_string();
237            self.paths
238                .entry(ident)
239                .or_default()
240                .insert(path_to_string(&node.path));
241        }
242
243        syn::visit::visit_expr_path(self, node);
244    }
245}
246
247struct PathVisitor {
248    issues: Vec<Issue>
249}
250
251impl PathVisitor {
252    fn check_path(&mut self, path: &Path) {
253        if PathImportAnalyzer::should_extract_to_import(path) {
254            let span = path.span();
255            let start = span.start();
256
257            let path_str = path
258                .segments
259                .iter()
260                .map(|s| s.ident.to_string())
261                .collect::<Vec<_>>()
262                .join("::");
263
264            let function_name = path
265                .segments
266                .last()
267                .map(|s| s.ident.to_string())
268                .unwrap_or_default();
269
270            self.issues.push(Issue {
271                line:    start.line,
272                column:  start.column,
273                message: format!("Use import instead of path: {}", path_str),
274                fix:     Fix::WithImport {
275                    import:      format!("use {};", path_str),
276                    pattern:     path_str.clone(),
277                    replacement: function_name
278                }
279            });
280        }
281    }
282}
283
284impl<'ast> syn::visit::Visit<'ast> for PathVisitor {
285    fn visit_expr_path(&mut self, node: &'ast ExprPath) {
286        self.check_path(&node.path);
287        syn::visit::visit_expr_path(self, node);
288    }
289}
290
291/// Rewrites qualified paths to short names and collects the imports to add.
292///
293/// For each expression path that
294/// [`PathImportAnalyzer::should_extract_to_import`] approves, the leading
295/// segments are dropped so only the final segment (with its generic arguments)
296/// remains, and a matching `use` item is queued for insertion at the top of the
297/// file.
298struct PathFixer {
299    fixed_count: usize,
300    imports:     Vec<Item>,
301    seen:        HashSet<String>,
302    blocked:     HashSet<String>
303}
304
305impl VisitMut for PathFixer {
306    fn visit_expr_path_mut(&mut self, node: &mut ExprPath) {
307        if node.qself.is_none() && PathImportAnalyzer::should_extract_to_import(&node.path) {
308            let path_str = path_to_string(&node.path);
309
310            let is_blocked = node
311                .path
312                .segments
313                .last()
314                .is_some_and(|last| self.blocked.contains(&last.ident.to_string()));
315
316            if let Some(last) = node.path.segments.last().cloned()
317                && !is_blocked
318            {
319                if self.seen.insert(path_str.clone())
320                    && let Ok(item_use) = syn::parse_str::<ItemUse>(&format!("use {};", path_str))
321                {
322                    self.imports.push(Item::Use(item_use));
323                }
324
325                let mut segments = Punctuated::new();
326                segments.push(last);
327                node.path.segments = segments;
328                node.path.leading_colon = None;
329
330                self.fixed_count += 1;
331            }
332        }
333
334        visit_mut::visit_expr_path_mut(self, node);
335    }
336}
337
338impl Default for PathImportAnalyzer {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use syn::parse_quote;
347
348    use super::*;
349
350    #[test]
351    fn test_analyzer_name() {
352        let analyzer = PathImportAnalyzer::new();
353        assert_eq!(analyzer.name(), "path_import");
354    }
355
356    #[test]
357    fn test_detect_path_separator() {
358        let analyzer = PathImportAnalyzer::new();
359        let code: File = parse_quote! {
360            fn main() {
361                let content = std::fs::read_to_string("file.txt");
362            }
363        };
364
365        let result = analyzer.analyze(&code, "").unwrap();
366        assert!(!result.issues.is_empty());
367    }
368
369    #[test]
370    fn test_ignore_enum_variants() {
371        let analyzer = PathImportAnalyzer::new();
372        let code: File = parse_quote! {
373            fn main() {
374                let err = AppError::NotFound;
375            }
376        };
377
378        let result = analyzer.analyze(&code, "").unwrap();
379        assert_eq!(result.issues.len(), 0);
380    }
381
382    #[test]
383    fn test_detect_stdlib_free_functions() {
384        let analyzer = PathImportAnalyzer::new();
385        let code: File = parse_quote! {
386            fn main() {
387                let content = std::fs::read_to_string("file.txt");
388                let result = std::io::stdin();
389                let data = core::mem::size_of::<u32>();
390            }
391        };
392
393        let result = analyzer.analyze(&code, "").unwrap();
394        assert_eq!(result.issues.len(), 3);
395    }
396
397    #[test]
398    fn test_ignore_associated_functions() {
399        let analyzer = PathImportAnalyzer::new();
400        let code: File = parse_quote! {
401            fn main() {
402                let v = Vec::new();
403                let s = String::from("hello");
404                let p = PathBuf::from("/path");
405                let m = std::collections::HashMap::new();
406            }
407        };
408
409        let result = analyzer.analyze(&code, "").unwrap();
410        assert_eq!(result.issues.len(), 0);
411    }
412
413    #[test]
414    fn test_ignore_option_result_variants() {
415        let analyzer = PathImportAnalyzer::new();
416        let code: File = parse_quote! {
417            fn main() {
418                let x = Option::Some(42);
419                let y = Option::None;
420                let ok = Result::Ok(1);
421                let err = Result::Err("error");
422            }
423        };
424
425        let result = analyzer.analyze(&code, "").unwrap();
426        assert_eq!(result.issues.len(), 0);
427    }
428
429    #[test]
430    fn test_ignore_associated_constants() {
431        let analyzer = PathImportAnalyzer::new();
432        let code: File = parse_quote! {
433            fn main() {
434                let max = u32::MAX;
435                let min = i64::MIN;
436                let pi = f64::consts::PI;
437            }
438        };
439
440        let result = analyzer.analyze(&code, "").unwrap();
441        assert_eq!(result.issues.len(), 0);
442    }
443
444    #[test]
445    fn test_detect_module_paths_3plus_segments() {
446        let analyzer = PathImportAnalyzer::new();
447        let code: File = parse_quote! {
448            fn main() {
449                let content = std::fs::read("file");
450                let data = std::io::stdin();
451            }
452        };
453
454        let result = analyzer.analyze(&code, "").unwrap();
455        assert_eq!(result.issues.len(), 2);
456    }
457
458    #[test]
459    fn test_mixed_scenarios() {
460        let analyzer = PathImportAnalyzer::new();
461        let code: File = parse_quote! {
462            fn main() {
463                let content = std::fs::read_to_string("file.txt");
464                let v = Vec::new();
465                let opt = Option::Some(42);
466                let max = u32::MAX;
467            }
468        };
469
470        let result = analyzer.analyze(&code, "").unwrap();
471        assert_eq!(result.issues.len(), 1);
472    }
473
474    #[test]
475    fn test_fix_rewrites_path_and_adds_import() {
476        let analyzer = PathImportAnalyzer::new();
477        let mut code: File = parse_quote! {
478            fn main() {
479                let content = std::fs::read_to_string("file.txt");
480            }
481        };
482
483        let fixed = analyzer.fix(&mut code).unwrap();
484        assert_eq!(fixed, 1);
485
486        let output = prettyplease::unparse(&code);
487        assert!(output.contains("use std::fs::read_to_string;"));
488        assert!(output.contains("read_to_string(\"file.txt\")"));
489        assert!(!output.contains("std::fs::read_to_string("));
490    }
491
492    #[test]
493    fn test_fix_returns_zero_without_issues() {
494        let analyzer = PathImportAnalyzer::new();
495        let mut code: File = parse_quote! {
496            fn main() {
497                let v = Vec::new();
498            }
499        };
500
501        let fixed = analyzer.fix(&mut code).unwrap();
502        assert_eq!(fixed, 0);
503    }
504
505    #[test]
506    fn test_fix_dedups_repeated_import() {
507        let analyzer = PathImportAnalyzer::new();
508        let mut code: File = parse_quote! {
509            fn main() {
510                let a = std::fs::read_to_string("a");
511                let b = std::fs::read_to_string("b");
512            }
513        };
514
515        let fixed = analyzer.fix(&mut code).unwrap();
516        assert_eq!(fixed, 2);
517
518        let output = prettyplease::unparse(&code);
519        assert_eq!(output.matches("use std::fs::read_to_string;").count(), 1);
520    }
521
522    #[test]
523    fn test_fix_skips_short_name_collision() {
524        let analyzer = PathImportAnalyzer::new();
525        let mut code: File = parse_quote! {
526            fn main() {
527                let a = std::fs::read("x");
528                let b = other::helpers::read("y");
529            }
530        };
531
532        let fixed = analyzer.fix(&mut code).unwrap();
533        assert_eq!(fixed, 0);
534
535        let output = prettyplease::unparse(&code);
536        assert!(output.contains("std::fs::read(\"x\")"));
537        assert!(output.contains("other::helpers::read(\"y\")"));
538        assert!(!output.contains("use std::fs::read;"));
539        assert!(!output.contains("use other::helpers::read;"));
540    }
541
542    #[test]
543    fn test_fix_same_path_repeated_is_not_collision() {
544        let analyzer = PathImportAnalyzer::new();
545        let mut code: File = parse_quote! {
546            fn main() {
547                let a = std::fs::read("x");
548                let b = std::fs::read("y");
549            }
550        };
551
552        let fixed = analyzer.fix(&mut code).unwrap();
553        assert_eq!(fixed, 2);
554
555        let output = prettyplease::unparse(&code);
556        assert_eq!(output.matches("use std::fs::read;").count(), 1);
557    }
558
559    #[test]
560    fn test_fix_preserves_generic_arguments() {
561        let analyzer = PathImportAnalyzer::new();
562        let mut code: File = parse_quote! {
563            fn main() {
564                let size = core::mem::size_of::<u32>();
565            }
566        };
567
568        let fixed = analyzer.fix(&mut code).unwrap();
569        assert_eq!(fixed, 1);
570
571        let output = prettyplease::unparse(&code);
572        assert!(output.contains("use core::mem::size_of;"));
573        assert!(output.contains("size_of::<u32>()"));
574    }
575
576    #[test]
577    fn test_default_implementation() {
578        let analyzer = PathImportAnalyzer;
579        assert_eq!(analyzer.name(), "path_import");
580    }
581
582    #[test]
583    fn test_single_segment_path() {
584        let analyzer = PathImportAnalyzer::new();
585        let code: File = parse_quote! {
586            fn main() {
587                println!("test");
588            }
589        };
590
591        let result = analyzer.analyze(&code, "").unwrap();
592        assert_eq!(result.issues.len(), 0);
593    }
594
595    #[test]
596    fn test_core_module_functions() {
597        let analyzer = PathImportAnalyzer::new();
598        let code: File = parse_quote! {
599            fn main() {
600                let size = core::mem::size_of::<u32>();
601            }
602        };
603
604        let result = analyzer.analyze(&code, "").unwrap();
605        assert!(!result.issues.is_empty());
606    }
607
608    #[test]
609    fn test_alloc_module_functions() {
610        let analyzer = PathImportAnalyzer::new();
611        let code: File = parse_quote! {
612            fn main() {
613                let data = alloc::format::format(format_args!("test"));
614            }
615        };
616
617        let result = analyzer.analyze(&code, "").unwrap();
618        assert!(!result.issues.is_empty());
619    }
620
621    #[test]
622    fn test_two_segment_path() {
623        let analyzer = PathImportAnalyzer::new();
624        let code: File = parse_quote! {
625            fn main() {
626                let x = fs::read("file");
627            }
628        };
629
630        let result = analyzer.analyze(&code, "").unwrap();
631        assert_eq!(result.issues.len(), 0);
632    }
633
634    #[test]
635    fn test_screaming_snake_case_constant() {
636        let analyzer = PathImportAnalyzer::new();
637        let code: File = parse_quote! {
638            fn main() {
639                let x = some::module::MAX_VALUE;
640            }
641        };
642
643        let result = analyzer.analyze(&code, "").unwrap();
644        assert_eq!(result.issues.len(), 0);
645    }
646
647    #[test]
648    fn test_path_with_generics() {
649        let analyzer = PathImportAnalyzer::new();
650        let code: File = parse_quote! {
651            fn main() {
652                let content = std::fs::read_to_string("file.txt");
653                let data = std::io::stdin();
654            }
655        };
656
657        let result = analyzer.analyze(&code, "").unwrap();
658        assert!(!result.issues.is_empty());
659    }
660
661    #[test]
662    fn test_result_fixable_count() {
663        let analyzer = PathImportAnalyzer::new();
664        let code: File = parse_quote! {
665            fn main() {
666                let a = std::fs::read_to_string("f");
667                let b = std::io::stdin();
668            }
669        };
670
671        let result = analyzer.analyze(&code, "").unwrap();
672        assert_eq!(result.fixable_count, result.issues.len());
673    }
674
675    #[test]
676    fn test_issue_format() {
677        let analyzer = PathImportAnalyzer::new();
678        let code: File = parse_quote! {
679            fn main() {
680                let x = std::fs::read("file");
681            }
682        };
683
684        let result = analyzer.analyze(&code, "").unwrap();
685        assert!(!result.issues.is_empty());
686        let issue = &result.issues[0];
687        assert!(issue.message.contains("Use import instead of path"));
688        assert!(issue.fix.is_available());
689        if let Some((import, pattern, replacement)) = issue.fix.as_import() {
690            assert!(import.contains("use"));
691            assert_eq!(pattern, "std::fs::read");
692            assert_eq!(replacement, "read");
693        } else {
694            panic!("Expected Fix::WithImport");
695        }
696    }
697}