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 masterror::AppResult;
14use syn::{
15    ExprMethodCall, ExprPath, File, Path,
16    spanned::Spanned,
17    visit::Visit,
18    visit_mut::{self, VisitMut}
19};
20
21use crate::analyzer::{AnalysisResult, Analyzer, Fix, Issue};
22
23/// Analyzer for detecting path separators that should be imports.
24///
25/// Detects module-level function calls using `::` syntax that should be
26/// converted to proper import statements for cleaner, more idiomatic code.
27///
28/// # Examples
29///
30/// Detects this pattern:
31/// ```ignore
32/// let content = std::fs::read_to_string("file.txt");
33/// ```
34///
35/// Suggests:
36/// ```ignore
37/// use std::fs::read_to_string;
38/// let content = read_to_string("file.txt");
39/// ```
40pub struct PathImportAnalyzer;
41
42impl PathImportAnalyzer {
43    /// Create new path import analyzer instance.
44    #[inline]
45    pub fn new() -> Self {
46        Self
47    }
48
49    /// Determine if path should be extracted to import statement.
50    ///
51    /// Analyzes path segments to distinguish module paths from type paths.
52    ///
53    /// # Arguments
54    ///
55    /// * `path` - Syntax path to analyze
56    ///
57    /// # Returns
58    ///
59    /// `true` if path represents free function that should be imported
60    fn should_extract_to_import(path: &Path) -> bool {
61        if path.segments.len() < 2 {
62            return false;
63        }
64
65        let first_segment = match path.segments.first() {
66            Some(seg) => seg,
67            None => return false
68        };
69
70        let first_name = first_segment.ident.to_string();
71
72        let first_char = match first_name.chars().next() {
73            Some(c) => c,
74            None => return false
75        };
76
77        if first_char.is_uppercase() {
78            return false;
79        }
80
81        let last_segment = match path.segments.last() {
82            Some(seg) => seg,
83            None => return false
84        };
85
86        let last_name = last_segment.ident.to_string();
87
88        if Self::is_screaming_snake_case(&last_name) {
89            return false;
90        }
91
92        let last_first_char = match last_name.chars().next() {
93            Some(c) => c,
94            None => return false
95        };
96
97        if last_first_char.is_uppercase() {
98            return false;
99        }
100
101        if path.segments.len() >= 2 {
102            let second_to_last = path.segments.iter().rev().nth(1);
103            if let Some(seg) = second_to_last {
104                let seg_name = seg.ident.to_string();
105                if let Some(c) = seg_name.chars().next()
106                    && c.is_uppercase()
107                {
108                    return false;
109                }
110            }
111        }
112
113        if Self::is_stdlib_root(&first_name) {
114            return true;
115        }
116
117        if path.segments.len() >= 3 && first_char.is_lowercase() {
118            return true;
119        }
120
121        false
122    }
123
124    /// Check if identifier is SCREAMING_SNAKE_CASE constant.
125    ///
126    /// # Arguments
127    ///
128    /// * `s` - Identifier string to check
129    ///
130    /// # Returns
131    ///
132    /// `true` if all characters are uppercase, underscore, or numeric
133    fn is_screaming_snake_case(s: &str) -> bool {
134        s.chars()
135            .all(|c| c.is_uppercase() || c == '_' || c.is_numeric())
136    }
137
138    /// Check if name is standard library root module.
139    ///
140    /// # Arguments
141    ///
142    /// * `name` - Module name to check
143    ///
144    /// # Returns
145    ///
146    /// `true` if name is `std`, `core`, or `alloc`
147    fn is_stdlib_root(name: &str) -> bool {
148        matches!(name, "std" | "core" | "alloc")
149    }
150}
151
152impl Analyzer for PathImportAnalyzer {
153    fn name(&self) -> &'static str {
154        "path_import"
155    }
156
157    fn analyze(&self, ast: &File, _content: &str) -> AppResult<AnalysisResult> {
158        let mut visitor = PathVisitor {
159            issues: Vec::new()
160        };
161        visitor.visit_file(ast);
162
163        let fixable_count = visitor.issues.len();
164
165        Ok(AnalysisResult {
166            issues: visitor.issues,
167            fixable_count
168        })
169    }
170
171    fn fix(&self, ast: &mut File) -> AppResult<usize> {
172        let mut fixer = PathFixer {
173            fixed_count: 0
174        };
175        fixer.visit_file_mut(ast);
176        Ok(fixer.fixed_count)
177    }
178}
179
180struct PathVisitor {
181    issues: Vec<Issue>
182}
183
184impl PathVisitor {
185    fn check_path(&mut self, path: &Path) {
186        if PathImportAnalyzer::should_extract_to_import(path) {
187            let span = path.span();
188            let start = span.start();
189
190            let path_str = path
191                .segments
192                .iter()
193                .map(|s| s.ident.to_string())
194                .collect::<Vec<_>>()
195                .join("::");
196
197            let function_name = path
198                .segments
199                .last()
200                .map(|s| s.ident.to_string())
201                .unwrap_or_default();
202
203            self.issues.push(Issue {
204                line:    start.line,
205                column:  start.column,
206                message: format!("Use import instead of path: {}", path_str),
207                fix:     Fix::WithImport {
208                    import:      format!("use {};", path_str),
209                    pattern:     path_str.clone(),
210                    replacement: function_name
211                }
212            });
213        }
214    }
215}
216
217impl<'ast> syn::visit::Visit<'ast> for PathVisitor {
218    fn visit_expr_path(&mut self, node: &'ast ExprPath) {
219        self.check_path(&node.path);
220        syn::visit::visit_expr_path(self, node);
221    }
222}
223
224struct PathFixer {
225    fixed_count: usize
226}
227
228impl VisitMut for PathFixer {
229    fn visit_expr_method_call_mut(&mut self, node: &mut ExprMethodCall) {
230        visit_mut::visit_expr_method_call_mut(self, node);
231    }
232}
233
234impl Default for PathImportAnalyzer {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use syn::parse_quote;
243
244    use super::*;
245
246    #[test]
247    fn test_analyzer_name() {
248        let analyzer = PathImportAnalyzer::new();
249        assert_eq!(analyzer.name(), "path_import");
250    }
251
252    #[test]
253    fn test_detect_path_separator() {
254        let analyzer = PathImportAnalyzer::new();
255        let code: File = parse_quote! {
256            fn main() {
257                let content = std::fs::read_to_string("file.txt");
258            }
259        };
260
261        let result = analyzer.analyze(&code, "").unwrap();
262        assert!(!result.issues.is_empty());
263    }
264
265    #[test]
266    fn test_ignore_enum_variants() {
267        let analyzer = PathImportAnalyzer::new();
268        let code: File = parse_quote! {
269            fn main() {
270                let err = AppError::NotFound;
271            }
272        };
273
274        let result = analyzer.analyze(&code, "").unwrap();
275        assert_eq!(result.issues.len(), 0);
276    }
277
278    #[test]
279    fn test_detect_stdlib_free_functions() {
280        let analyzer = PathImportAnalyzer::new();
281        let code: File = parse_quote! {
282            fn main() {
283                let content = std::fs::read_to_string("file.txt");
284                let result = std::io::stdin();
285                let data = core::mem::size_of::<u32>();
286            }
287        };
288
289        let result = analyzer.analyze(&code, "").unwrap();
290        assert_eq!(result.issues.len(), 3);
291    }
292
293    #[test]
294    fn test_ignore_associated_functions() {
295        let analyzer = PathImportAnalyzer::new();
296        let code: File = parse_quote! {
297            fn main() {
298                let v = Vec::new();
299                let s = String::from("hello");
300                let p = PathBuf::from("/path");
301                let m = std::collections::HashMap::new();
302            }
303        };
304
305        let result = analyzer.analyze(&code, "").unwrap();
306        assert_eq!(result.issues.len(), 0);
307    }
308
309    #[test]
310    fn test_ignore_option_result_variants() {
311        let analyzer = PathImportAnalyzer::new();
312        let code: File = parse_quote! {
313            fn main() {
314                let x = Option::Some(42);
315                let y = Option::None;
316                let ok = Result::Ok(1);
317                let err = Result::Err("error");
318            }
319        };
320
321        let result = analyzer.analyze(&code, "").unwrap();
322        assert_eq!(result.issues.len(), 0);
323    }
324
325    #[test]
326    fn test_ignore_associated_constants() {
327        let analyzer = PathImportAnalyzer::new();
328        let code: File = parse_quote! {
329            fn main() {
330                let max = u32::MAX;
331                let min = i64::MIN;
332                let pi = f64::consts::PI;
333            }
334        };
335
336        let result = analyzer.analyze(&code, "").unwrap();
337        assert_eq!(result.issues.len(), 0);
338    }
339
340    #[test]
341    fn test_detect_module_paths_3plus_segments() {
342        let analyzer = PathImportAnalyzer::new();
343        let code: File = parse_quote! {
344            fn main() {
345                let content = std::fs::read("file");
346                let data = std::io::stdin();
347            }
348        };
349
350        let result = analyzer.analyze(&code, "").unwrap();
351        assert_eq!(result.issues.len(), 2);
352    }
353
354    #[test]
355    fn test_mixed_scenarios() {
356        let analyzer = PathImportAnalyzer::new();
357        let code: File = parse_quote! {
358            fn main() {
359                let content = std::fs::read_to_string("file.txt");
360                let v = Vec::new();
361                let opt = Option::Some(42);
362                let max = u32::MAX;
363            }
364        };
365
366        let result = analyzer.analyze(&code, "").unwrap();
367        assert_eq!(result.issues.len(), 1);
368    }
369
370    #[test]
371    fn test_fix_returns_zero() {
372        let analyzer = PathImportAnalyzer::new();
373        let mut code: File = parse_quote! {
374            fn main() {
375                let content = std::fs::read_to_string("file.txt");
376            }
377        };
378
379        let fixed = analyzer.fix(&mut code).unwrap();
380        assert_eq!(fixed, 0);
381    }
382
383    #[test]
384    fn test_default_implementation() {
385        let analyzer = PathImportAnalyzer;
386        assert_eq!(analyzer.name(), "path_import");
387    }
388
389    #[test]
390    fn test_single_segment_path() {
391        let analyzer = PathImportAnalyzer::new();
392        let code: File = parse_quote! {
393            fn main() {
394                println!("test");
395            }
396        };
397
398        let result = analyzer.analyze(&code, "").unwrap();
399        assert_eq!(result.issues.len(), 0);
400    }
401
402    #[test]
403    fn test_core_module_functions() {
404        let analyzer = PathImportAnalyzer::new();
405        let code: File = parse_quote! {
406            fn main() {
407                let size = core::mem::size_of::<u32>();
408            }
409        };
410
411        let result = analyzer.analyze(&code, "").unwrap();
412        assert!(!result.issues.is_empty());
413    }
414
415    #[test]
416    fn test_alloc_module_functions() {
417        let analyzer = PathImportAnalyzer::new();
418        let code: File = parse_quote! {
419            fn main() {
420                let data = alloc::format::format(format_args!("test"));
421            }
422        };
423
424        let result = analyzer.analyze(&code, "").unwrap();
425        assert!(!result.issues.is_empty());
426    }
427
428    #[test]
429    fn test_two_segment_path() {
430        let analyzer = PathImportAnalyzer::new();
431        let code: File = parse_quote! {
432            fn main() {
433                let x = fs::read("file");
434            }
435        };
436
437        let result = analyzer.analyze(&code, "").unwrap();
438        assert_eq!(result.issues.len(), 0);
439    }
440
441    #[test]
442    fn test_screaming_snake_case_constant() {
443        let analyzer = PathImportAnalyzer::new();
444        let code: File = parse_quote! {
445            fn main() {
446                let x = some::module::MAX_VALUE;
447            }
448        };
449
450        let result = analyzer.analyze(&code, "").unwrap();
451        assert_eq!(result.issues.len(), 0);
452    }
453
454    #[test]
455    fn test_path_with_generics() {
456        let analyzer = PathImportAnalyzer::new();
457        let code: File = parse_quote! {
458            fn main() {
459                let content = std::fs::read_to_string("file.txt");
460                let data = std::io::stdin();
461            }
462        };
463
464        let result = analyzer.analyze(&code, "").unwrap();
465        assert!(!result.issues.is_empty());
466    }
467
468    #[test]
469    fn test_result_fixable_count() {
470        let analyzer = PathImportAnalyzer::new();
471        let code: File = parse_quote! {
472            fn main() {
473                let a = std::fs::read_to_string("f");
474                let b = std::io::stdin();
475            }
476        };
477
478        let result = analyzer.analyze(&code, "").unwrap();
479        assert_eq!(result.fixable_count, result.issues.len());
480    }
481
482    #[test]
483    fn test_issue_format() {
484        let analyzer = PathImportAnalyzer::new();
485        let code: File = parse_quote! {
486            fn main() {
487                let x = std::fs::read("file");
488            }
489        };
490
491        let result = analyzer.analyze(&code, "").unwrap();
492        assert!(!result.issues.is_empty());
493        let issue = &result.issues[0];
494        assert!(issue.message.contains("Use import instead of path"));
495        assert!(issue.fix.is_available());
496        if let Some((import, pattern, replacement)) = issue.fix.as_import() {
497            assert!(import.contains("use"));
498            assert_eq!(pattern, "std::fs::read");
499            assert_eq!(replacement, "read");
500        } else {
501            panic!("Expected Fix::WithImport");
502        }
503    }
504}