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