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 absolute module paths (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//!
13//! Only absolute paths — rooted at `std`, `core`, `alloc`, or `crate` — are
14//! rewritten. Relative paths (including `self::` and `super::`) resolve
15//! against the module they appear in, so hoisting them into a `use` statement
16//! would change their meaning.
17//!
18//! Fixes are scope-aware: each rewrite consults a per-module symbol table
19//! built from the file (item definitions, `use`-bound names, glob imports,
20//! and local bindings), the required `use` statement is inserted into the
21//! module containing the rewritten path, and a rewrite is skipped whenever
22//! the bare name could collide with or silently rebind an existing name.
23
24use std::{
25    collections::{HashMap, HashSet},
26    ops::Range
27};
28
29use masterror::AppResult;
30use syn::{ExprPath, File, Item, ItemUse, Path, UseTree, spanned::Spanned, visit::Visit};
31
32use crate::{
33    analyzer::{AnalysisResult, Analyzer, Fix, ImportEdit, Issue, Suggestion, TextEdit},
34    fixer::import_insertion_offset
35};
36
37/// Analyzer for detecting path separators that should be imports.
38///
39/// Detects module-level function calls using `::` syntax that should be
40/// converted to proper import statements for cleaner, more idiomatic code.
41///
42/// # Examples
43///
44/// Detects this pattern:
45/// ```ignore
46/// let content = std::fs::read_to_string("file.txt");
47/// ```
48///
49/// Suggests:
50/// ```ignore
51/// use std::fs::read_to_string;
52/// let content = read_to_string("file.txt");
53/// ```
54pub struct PathImportAnalyzer;
55
56impl PathImportAnalyzer {
57    /// Create new path import analyzer instance.
58    #[inline]
59    pub fn new() -> Self {
60        Self
61    }
62
63    /// Determine if path should be extracted to import statement.
64    ///
65    /// Only absolute paths rooted at `std`, `core`, `alloc`, or `crate` whose
66    /// final segment names a free function are accepted. Type paths, enum
67    /// variants, associated items, and relative module paths are rejected.
68    ///
69    /// # Arguments
70    ///
71    /// * `path` - Syntax path to analyze
72    ///
73    /// # Returns
74    ///
75    /// `true` if path represents free function that should be imported
76    fn should_extract_to_import(path: &Path) -> bool {
77        if path.segments.len() < 2 {
78            return false;
79        }
80
81        let first_segment = match path.segments.first() {
82            Some(seg) => seg,
83            None => return false
84        };
85
86        let first_name = first_segment.ident.to_string();
87
88        let first_char = match first_name.chars().next() {
89            Some(c) => c,
90            None => return false
91        };
92
93        if first_char.is_uppercase() {
94            return false;
95        }
96
97        let last_segment = match path.segments.last() {
98            Some(seg) => seg,
99            None => return false
100        };
101
102        let last_name = last_segment.ident.to_string();
103
104        if Self::is_screaming_snake_case(&last_name) {
105            return false;
106        }
107
108        let last_first_char = match last_name.chars().next() {
109            Some(c) => c,
110            None => return false
111        };
112
113        if last_first_char.is_uppercase() {
114            return false;
115        }
116
117        if path.segments.len() >= 2 {
118            let second_to_last = path.segments.iter().rev().nth(1);
119            if let Some(seg) = second_to_last {
120                let seg_name = seg.ident.to_string();
121                if let Some(c) = seg_name.chars().next()
122                    && c.is_uppercase()
123                {
124                    return false;
125                }
126            }
127        }
128
129        Self::is_extractable_root(&first_name)
130    }
131
132    /// Check if identifier is SCREAMING_SNAKE_CASE constant.
133    ///
134    /// # Arguments
135    ///
136    /// * `s` - Identifier string to check
137    ///
138    /// # Returns
139    ///
140    /// `true` if all characters are uppercase, underscore, or numeric
141    fn is_screaming_snake_case(s: &str) -> bool {
142        s.chars()
143            .all(|c| c.is_uppercase() || c == '_' || c.is_numeric())
144    }
145
146    /// Check if name roots an absolute path that is safe to import from.
147    ///
148    /// Relative roots (module names, `self`, `super`) resolve against the
149    /// module the path appears in, so hoisting them into a `use` statement
150    /// would change their meaning.
151    ///
152    /// # Arguments
153    ///
154    /// * `name` - Root segment name to check
155    ///
156    /// # Returns
157    ///
158    /// `true` if name is `std`, `core`, `alloc`, or `crate`
159    fn is_extractable_root(name: &str) -> bool {
160        matches!(name, "std" | "core" | "alloc" | "crate")
161    }
162}
163
164impl Analyzer for PathImportAnalyzer {
165    fn name(&self) -> &'static str {
166        "path_import"
167    }
168
169    fn analyze(&self, ast: &File, _content: &str) -> AppResult<AnalysisResult> {
170        let mut visitor = PathVisitor {
171            issues: Vec::new()
172        };
173        visitor.visit_file(ast);
174
175        let fixable_count = visitor.issues.len();
176
177        Ok(AnalysisResult {
178            issues: visitor.issues,
179            fixable_count
180        })
181    }
182
183    fn suggestions(&self, ast: &File, content: &str) -> AppResult<Vec<Suggestion>> {
184        let root = ModuleScope::build(&ast.items, Some(import_insertion_offset(content)));
185
186        let mut suggestions = Vec::new();
187        root.collect_suggestions(&HashSet::new(), false, &mut suggestions);
188
189        Ok(suggestions)
190    }
191}
192
193/// Full colon-joined path string of an expression path.
194///
195/// # Arguments
196///
197/// * `path` - Path to render
198///
199/// # Returns
200///
201/// Segments joined with `::`
202fn path_to_string(path: &Path) -> String {
203    path.segments
204        .iter()
205        .map(|segment| segment.ident.to_string())
206        .collect::<Vec<_>>()
207        .join("::")
208}
209
210/// Name an item binds in its enclosing module, if any.
211///
212/// # Arguments
213///
214/// * `item` - Item to inspect
215///
216/// # Returns
217///
218/// The bound identifier, or `None` for items that bind no single name
219fn item_bound_name(item: &Item) -> Option<String> {
220    match item {
221        Item::Const(item) => Some(item.ident.to_string()),
222        Item::Enum(item) => Some(item.ident.to_string()),
223        Item::ExternCrate(item) => Some(
224            item.rename
225                .as_ref()
226                .map_or_else(|| item.ident.to_string(), |(_, rename)| rename.to_string())
227        ),
228        Item::Fn(item) => Some(item.sig.ident.to_string()),
229        Item::Macro(item) => item.ident.as_ref().map(|ident| ident.to_string()),
230        Item::Mod(item) => Some(item.ident.to_string()),
231        Item::Static(item) => Some(item.ident.to_string()),
232        Item::Struct(item) => Some(item.ident.to_string()),
233        Item::Trait(item) => Some(item.ident.to_string()),
234        Item::TraitAlias(item) => Some(item.ident.to_string()),
235        Item::Type(item) => Some(item.ident.to_string()),
236        Item::Union(item) => Some(item.ident.to_string()),
237        _ => None
238    }
239}
240
241/// A qualified path eligible for rewriting to a bare imported name.
242struct Candidate {
243    /// Full colon-joined path
244    path:  String,
245    /// Final segment the rewrite leaves behind
246    ident: String,
247    /// Byte range of the leading segments to delete
248    range: Range<usize>
249}
250
251/// Names one module binds and the bare identifiers its code relies on.
252///
253/// Mirrors how name resolution sees the module: names bound by items, `use`
254/// statements, and local bindings, whether glob imports bring in unknown
255/// names, and which bare identifiers the module's code already uses.
256#[derive(Default)]
257struct SymbolTable {
258    /// Names bound in this module: items, `use`-bound names, local bindings
259    bound:            HashSet<String>,
260    /// Bare (single-segment) identifiers used in expressions in this module
261    bare_idents:      HashSet<String>,
262    /// Whether the module has `use super::*`
263    has_super_glob:   bool,
264    /// Whether the module has a glob import other than `use super::*`
265    has_foreign_glob: bool
266}
267
268impl SymbolTable {
269    /// Records the names and glob imports a `use` statement introduces.
270    ///
271    /// # Arguments
272    ///
273    /// * `item` - The `use` statement to record
274    fn record_use(&mut self, item: &ItemUse) {
275        let mut prefix = Vec::new();
276        self.record_use_tree(&item.tree, &mut prefix);
277    }
278
279    /// Walks a use tree, recording bound names and glob imports.
280    ///
281    /// `use a::b::{self}` binds `b`, renames bind the new name, and globs set
282    /// the matching flag: `use super::*` inherits the parent scope, while any
283    /// other glob brings in names this analysis cannot enumerate.
284    ///
285    /// # Arguments
286    ///
287    /// * `tree` - Use tree node to walk
288    /// * `prefix` - Path segments accumulated above this node
289    fn record_use_tree(&mut self, tree: &UseTree, prefix: &mut Vec<String>) {
290        match tree {
291            UseTree::Path(path) => {
292                prefix.push(path.ident.to_string());
293                self.record_use_tree(&path.tree, prefix);
294                prefix.pop();
295            }
296            UseTree::Name(name) => {
297                let ident = name.ident.to_string();
298                if ident == "self" {
299                    if let Some(parent) = prefix.last() {
300                        self.bound.insert(parent.clone());
301                    }
302                } else {
303                    self.bound.insert(ident);
304                }
305            }
306            UseTree::Rename(rename) => {
307                self.bound.insert(rename.rename.to_string());
308            }
309            UseTree::Glob(_) => {
310                if prefix.len() == 1 && prefix[0] == "super" {
311                    self.has_super_glob = true;
312                } else {
313                    self.has_foreign_glob = true;
314                }
315            }
316            UseTree::Group(group) => {
317                for tree in &group.items {
318                    self.record_use_tree(tree, prefix);
319                }
320            }
321        }
322    }
323}
324
325/// Scope tree node: one module's symbols, rewrite candidates, and children.
326struct ModuleScope {
327    /// Names bound in and bare identifiers used by this module
328    symbols:       SymbolTable,
329    /// Byte offset at which to insert `use` statements for this module
330    insert_offset: Option<usize>,
331    /// Rewrite candidates found directly in this module
332    candidates:    Vec<Candidate>,
333    /// Inline child modules
334    children:      Vec<ModuleScope>
335}
336
337impl ModuleScope {
338    /// Builds the scope tree for a module's items.
339    ///
340    /// Inline child modules become child scopes; `use` statements and item
341    /// definitions populate the symbol table; function bodies are scanned for
342    /// rewrite candidates, bare identifier usage, and local bindings.
343    ///
344    /// # Arguments
345    ///
346    /// * `items` - Items of the module
347    /// * `insert_offset` - Byte offset for this module's `use` insertions
348    ///
349    /// # Returns
350    ///
351    /// The populated scope for this module and its descendants
352    fn build(items: &[Item], insert_offset: Option<usize>) -> Self {
353        let mut scope = Self {
354            symbols: SymbolTable::default(),
355            insert_offset,
356            candidates: Vec::new(),
357            children: Vec::new()
358        };
359
360        for item in items {
361            if let Item::Mod(module) = item {
362                scope.symbols.bound.insert(module.ident.to_string());
363                if let Some((_, child_items)) = &module.content {
364                    let child_offset = child_items
365                        .first()
366                        .map(|first| first.span().byte_range().start);
367                    scope.children.push(Self::build(child_items, child_offset));
368                }
369                continue;
370            }
371
372            let mut collector = BodyCollector {
373                scope: &mut scope
374            };
375            collector.visit_item(item);
376        }
377
378        scope
379    }
380
381    /// Final identifiers reachable from more than one distinct path here.
382    ///
383    /// Rewriting such an identifier would create ambiguous imports inside
384    /// this module, so those paths are left qualified.
385    ///
386    /// # Returns
387    ///
388    /// Set of ambiguous final identifiers
389    fn ambiguous_idents(&self) -> HashSet<String> {
390        let mut sources: HashMap<&str, &str> = HashMap::new();
391        let mut ambiguous = HashSet::new();
392
393        for candidate in &self.candidates {
394            match sources.get(candidate.ident.as_str()) {
395                Some(path) if *path != candidate.path.as_str() => {
396                    ambiguous.insert(candidate.ident.clone());
397                }
398                Some(_) => {}
399                None => {
400                    sources.insert(&candidate.ident, &candidate.path);
401                }
402            }
403        }
404
405        ambiguous
406    }
407
408    /// Whether importing a name here would rebind a descendant's bare usage.
409    ///
410    /// A descendant module reachable through a chain of `use super::*` globs
411    /// sees names imported here. If such a descendant already uses the name
412    /// bare without binding it itself, adding the import could silently
413    /// change what that usage resolves to.
414    ///
415    /// # Arguments
416    ///
417    /// * `name` - Candidate import name to check
418    ///
419    /// # Returns
420    ///
421    /// `true` if a glob-inheriting descendant uses the name unbound
422    fn descendant_bare_conflict(&self, name: &str) -> bool {
423        self.children.iter().any(|child| {
424            child.symbols.has_super_glob
425                && ((child.symbols.bare_idents.contains(name)
426                    && !child.symbols.bound.contains(name))
427                    || child.descendant_bare_conflict(name))
428        })
429    }
430
431    /// Emits scope-safe suggestions for this module and its descendants.
432    ///
433    /// A candidate is rewritten only when its bare name is not already bound
434    /// in the effective scope (own names plus names inherited through
435    /// `use super::*`), is unambiguous among this module's candidates, and
436    /// cannot rebind a descendant's bare usage. Modules whose scope contains
437    /// a glob of unknown names produce no rewrites at all.
438    ///
439    /// # Arguments
440    ///
441    /// * `inherited_bound` - Names visible from ancestors via `use super::*`
442    /// * `inherited_foreign_glob` - Whether ancestors leak unknown glob names
443    /// * `suggestions` - Output collection
444    fn collect_suggestions(
445        &self,
446        inherited_bound: &HashSet<String>,
447        inherited_foreign_glob: bool,
448        suggestions: &mut Vec<Suggestion>
449    ) {
450        let foreign_glob = self.symbols.has_foreign_glob
451            || (self.symbols.has_super_glob && inherited_foreign_glob);
452
453        let mut visible = self.symbols.bound.clone();
454        if self.symbols.has_super_glob {
455            visible.extend(inherited_bound.iter().cloned());
456        }
457
458        if !foreign_glob && let Some(offset) = self.insert_offset {
459            let ambiguous = self.ambiguous_idents();
460
461            for candidate in &self.candidates {
462                if ambiguous.contains(&candidate.ident)
463                    || visible.contains(&candidate.ident)
464                    || self.descendant_bare_conflict(&candidate.ident)
465                {
466                    continue;
467                }
468
469                suggestions.push(Suggestion {
470                    edit:   TextEdit {
471                        range:       candidate.range.clone(),
472                        replacement: String::new()
473                    },
474                    import: Some(ImportEdit {
475                        offset,
476                        statement: format!("use {};", candidate.path)
477                    })
478                });
479            }
480        }
481
482        for child in &self.children {
483            child.collect_suggestions(&visible, foreign_glob, suggestions);
484        }
485    }
486}
487
488/// Scans one non-module item of a module for names and rewrite candidates.
489///
490/// Collects rewrite candidates, bare identifier usage, and local bindings
491/// into the owning [`ModuleScope`]. Function-local `use` statements are
492/// recorded conservatively as if module-level; function-local modules are
493/// left untouched (no candidates, no bindings).
494struct BodyCollector<'scope> {
495    /// Scope receiving the collected facts
496    scope: &'scope mut ModuleScope
497}
498
499impl<'scope, 'ast> Visit<'ast> for BodyCollector<'scope> {
500    fn visit_item(&mut self, node: &'ast Item) {
501        match node {
502            Item::Mod(_) => {}
503            Item::Use(item) => {
504                self.scope.symbols.record_use(item);
505            }
506            other => {
507                if let Some(name) = item_bound_name(other) {
508                    self.scope.symbols.bound.insert(name);
509                }
510                syn::visit::visit_item(self, node);
511            }
512        }
513    }
514
515    fn visit_expr_path(&mut self, node: &'ast ExprPath) {
516        if node.qself.is_none() {
517            if node.path.segments.len() == 1 {
518                if let Some(only) = node.path.segments.first() {
519                    self.scope
520                        .symbols
521                        .bare_idents
522                        .insert(only.ident.to_string());
523                }
524            } else if PathImportAnalyzer::should_extract_to_import(&node.path)
525                && let Some(last) = node.path.segments.last()
526            {
527                let path_start = node.path.span().byte_range().start;
528                let last_start = last.ident.span().byte_range().start;
529
530                if last_start > path_start {
531                    self.scope.candidates.push(Candidate {
532                        path:  path_to_string(&node.path),
533                        ident: last.ident.to_string(),
534                        range: path_start..last_start
535                    });
536                }
537            }
538        }
539
540        syn::visit::visit_expr_path(self, node);
541    }
542
543    fn visit_pat_ident(&mut self, node: &'ast syn::PatIdent) {
544        self.scope.symbols.bound.insert(node.ident.to_string());
545        syn::visit::visit_pat_ident(self, node);
546    }
547}
548
549struct PathVisitor {
550    issues: Vec<Issue>
551}
552
553impl PathVisitor {
554    fn check_path(&mut self, path: &Path) {
555        if PathImportAnalyzer::should_extract_to_import(path) {
556            let span = path.span();
557            let start = span.start();
558
559            let path_str = path_to_string(path);
560
561            let function_name = path
562                .segments
563                .last()
564                .map(|s| s.ident.to_string())
565                .unwrap_or_default();
566
567            self.issues.push(Issue::new(
568                start.line,
569                start.column,
570                format!("Use import instead of path: {}", path_str),
571                Fix::WithImport {
572                    import:      format!("use {};", path_str),
573                    pattern:     path_str.clone(),
574                    replacement: function_name
575                }
576            ));
577        }
578    }
579}
580
581impl<'ast> Visit<'ast> for PathVisitor {
582    fn visit_expr_path(&mut self, node: &'ast ExprPath) {
583        self.check_path(&node.path);
584        syn::visit::visit_expr_path(self, node);
585    }
586}
587
588impl Default for PathImportAnalyzer {
589    fn default() -> Self {
590        Self::new()
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use syn::parse_quote;
597
598    use super::*;
599
600    #[test]
601    fn test_analyzer_name() {
602        let analyzer = PathImportAnalyzer::new();
603        assert_eq!(analyzer.name(), "path_import");
604    }
605
606    #[test]
607    fn test_detect_path_separator() {
608        let analyzer = PathImportAnalyzer::new();
609        let code: File = parse_quote! {
610            fn main() {
611                let content = std::fs::read_to_string("file.txt");
612            }
613        };
614
615        let result = analyzer.analyze(&code, "").unwrap();
616        assert!(!result.issues.is_empty());
617    }
618
619    #[test]
620    fn test_ignore_enum_variants() {
621        let analyzer = PathImportAnalyzer::new();
622        let code: File = parse_quote! {
623            fn main() {
624                let err = AppError::NotFound;
625            }
626        };
627
628        let result = analyzer.analyze(&code, "").unwrap();
629        assert_eq!(result.issues.len(), 0);
630    }
631
632    #[test]
633    fn test_detect_stdlib_free_functions() {
634        let analyzer = PathImportAnalyzer::new();
635        let code: File = parse_quote! {
636            fn main() {
637                let content = std::fs::read_to_string("file.txt");
638                let result = std::io::stdin();
639                let data = core::mem::size_of::<u32>();
640            }
641        };
642
643        let result = analyzer.analyze(&code, "").unwrap();
644        assert_eq!(result.issues.len(), 3);
645    }
646
647    #[test]
648    fn test_ignore_associated_functions() {
649        let analyzer = PathImportAnalyzer::new();
650        let code: File = parse_quote! {
651            fn main() {
652                let v = Vec::new();
653                let s = String::from("hello");
654                let p = PathBuf::from("/path");
655                let m = std::collections::HashMap::new();
656            }
657        };
658
659        let result = analyzer.analyze(&code, "").unwrap();
660        assert_eq!(result.issues.len(), 0);
661    }
662
663    #[test]
664    fn test_ignore_option_result_variants() {
665        let analyzer = PathImportAnalyzer::new();
666        let code: File = parse_quote! {
667            fn main() {
668                let x = Option::Some(42);
669                let y = Option::None;
670                let ok = Result::Ok(1);
671                let err = Result::Err("error");
672            }
673        };
674
675        let result = analyzer.analyze(&code, "").unwrap();
676        assert_eq!(result.issues.len(), 0);
677    }
678
679    #[test]
680    fn test_ignore_associated_constants() {
681        let analyzer = PathImportAnalyzer::new();
682        let code: File = parse_quote! {
683            fn main() {
684                let max = u32::MAX;
685                let min = i64::MIN;
686                let pi = f64::consts::PI;
687            }
688        };
689
690        let result = analyzer.analyze(&code, "").unwrap();
691        assert_eq!(result.issues.len(), 0);
692    }
693
694    #[test]
695    fn test_ignore_relative_module_paths() {
696        let analyzer = PathImportAnalyzer::new();
697        let code: File = parse_quote! {
698            fn main() {
699                let data = helpers::io::load("file");
700                let more = my_mod::sub::func();
701            }
702        };
703
704        let result = analyzer.analyze(&code, "").unwrap();
705        assert_eq!(result.issues.len(), 0);
706    }
707
708    #[test]
709    fn test_ignore_super_and_self_paths() {
710        let analyzer = PathImportAnalyzer::new();
711        let code: File = parse_quote! {
712            mod inner {
713                fn f() {
714                    super::helpers::run();
715                    self::local::call();
716                }
717            }
718        };
719
720        let result = analyzer.analyze(&code, "").unwrap();
721        assert_eq!(result.issues.len(), 0);
722    }
723
724    #[test]
725    fn test_detect_crate_rooted_paths() {
726        let analyzer = PathImportAnalyzer::new();
727        let code: File = parse_quote! {
728            fn main() {
729                crate::util::helper();
730            }
731        };
732
733        let result = analyzer.analyze(&code, "").unwrap();
734        assert_eq!(result.issues.len(), 1);
735    }
736
737    #[test]
738    fn test_mixed_scenarios() {
739        let analyzer = PathImportAnalyzer::new();
740        let code: File = parse_quote! {
741            fn main() {
742                let content = std::fs::read_to_string("file.txt");
743                let v = Vec::new();
744                let opt = Option::Some(42);
745                let max = u32::MAX;
746            }
747        };
748
749        let result = analyzer.analyze(&code, "").unwrap();
750        assert_eq!(result.issues.len(), 1);
751    }
752
753    fn apply_fix(content: &str) -> (usize, String) {
754        let analyzer = PathImportAnalyzer::new();
755        let ast = syn::parse_file(content).unwrap();
756        let suggestions = analyzer.suggestions(&ast, content).unwrap();
757        let fixed = suggestions.len();
758        let output = crate::fixer::apply_suggestions(content, &suggestions);
759        (fixed, output)
760    }
761
762    #[test]
763    fn test_fix_rewrites_path_and_adds_import() {
764        let content = "fn main() {\n    let content = std::fs::read_to_string(\"file.txt\");\n}\n";
765        let (fixed, output) = apply_fix(content);
766
767        assert_eq!(fixed, 1);
768        assert!(output.contains("use std::fs::read_to_string;"));
769        assert!(output.contains("read_to_string(\"file.txt\")"));
770        assert!(!output.contains("std::fs::read_to_string("));
771    }
772
773    #[test]
774    fn test_fix_preserves_comments_and_blank_lines() {
775        let content = "// top comment\nfn main() {\n    // inline note\n    let x = std::fs::read_to_string(\"f\");\n\n    let _ = x;\n}\n";
776        let (fixed, output) = apply_fix(content);
777
778        assert_eq!(fixed, 1);
779        assert!(output.contains("// top comment"), "top comment preserved");
780        assert!(
781            output.contains("// inline note"),
782            "inline comment preserved"
783        );
784        assert!(
785            output.contains("\n\n    let _ = x;"),
786            "blank line preserved"
787        );
788        assert!(output.contains("use std::fs::read_to_string;"));
789        assert!(output.contains("let x = read_to_string(\"f\");"));
790    }
791
792    #[test]
793    fn test_fix_returns_zero_without_issues() {
794        let content = "fn main() {\n    let v = Vec::new();\n}\n";
795        let (fixed, output) = apply_fix(content);
796
797        assert_eq!(fixed, 0);
798        assert_eq!(output, content);
799    }
800
801    #[test]
802    fn test_fix_dedups_repeated_import() {
803        let content = "fn main() {\n    let a = std::fs::read_to_string(\"a\");\n    let b = std::fs::read_to_string(\"b\");\n}\n";
804        let (fixed, output) = apply_fix(content);
805
806        assert_eq!(fixed, 2);
807        assert_eq!(output.matches("use std::fs::read_to_string;").count(), 1);
808    }
809
810    #[test]
811    fn test_fix_skips_short_name_collision() {
812        let content = "fn main() {\n    let a = std::fs::read(\"x\");\n    let b = crate::helpers::read(\"y\");\n}\n";
813        let (fixed, output) = apply_fix(content);
814
815        assert_eq!(fixed, 0);
816        assert!(output.contains("std::fs::read(\"x\")"));
817        assert!(output.contains("crate::helpers::read(\"y\")"));
818        assert!(!output.contains("use std::fs::read;"));
819        assert!(!output.contains("use crate::helpers::read;"));
820    }
821
822    #[test]
823    fn test_fix_same_path_repeated_is_not_collision() {
824        let content = "fn main() {\n    let a = std::fs::read(\"x\");\n    let b = std::fs::read(\"y\");\n}\n";
825        let (fixed, output) = apply_fix(content);
826
827        assert_eq!(fixed, 2);
828        assert_eq!(output.matches("use std::fs::read;").count(), 1);
829    }
830
831    #[test]
832    fn test_fix_preserves_generic_arguments() {
833        let content = "fn main() {\n    let size = core::mem::size_of::<u32>();\n}\n";
834        let (fixed, output) = apply_fix(content);
835
836        assert_eq!(fixed, 1);
837        assert!(output.contains("use core::mem::size_of;"));
838        assert!(output.contains("size_of::<u32>()"));
839    }
840
841    #[test]
842    fn test_fix_skips_name_bound_by_existing_import() {
843        let content =
844            "use crate::util::read;\n\nfn main() {\n    let a = std::fs::read(\"x\");\n}\n";
845        let (fixed, output) = apply_fix(content);
846
847        assert_eq!(fixed, 0);
848        assert_eq!(output, content);
849    }
850
851    #[test]
852    fn test_fix_skips_already_imported_path() {
853        let content = "use std::fs::read;\n\nfn main() {\n    let a = std::fs::read(\"x\");\n}\n";
854        let (fixed, output) = apply_fix(content);
855
856        assert_eq!(fixed, 0);
857        assert_eq!(output, content);
858    }
859
860    #[test]
861    fn test_fix_skips_name_bound_by_local_fn() {
862        let content = "fn read(path: &str) -> &str {\n    path\n}\n\nfn main() {\n    let a = std::fs::read(\"x\");\n}\n";
863        let (fixed, output) = apply_fix(content);
864
865        assert_eq!(fixed, 0);
866        assert_eq!(output, content);
867    }
868
869    #[test]
870    fn test_fix_skips_name_bound_by_local_binding() {
871        let content = "fn main() {\n    let read = 1;\n    let a = std::fs::read(\"x\");\n    let _ = read;\n}\n";
872        let (fixed, output) = apply_fix(content);
873
874        assert_eq!(fixed, 0);
875        assert_eq!(output, content);
876    }
877
878    #[test]
879    fn test_fix_skips_module_with_foreign_glob() {
880        let content = "use helpers::*;\n\nfn main() {\n    let a = std::fs::read(\"x\");\n}\n";
881        let (fixed, output) = apply_fix(content);
882
883        assert_eq!(fixed, 0);
884        assert_eq!(output, content);
885    }
886
887    #[test]
888    fn test_fix_inserts_import_into_nested_module() {
889        let content = "fn top() {\n    let a = std::fs::read_to_string(\"a\");\n}\n\nmod inner {\n    fn f() {\n        let b = std::fs::read_to_string(\"b\");\n    }\n}\n";
890        let (fixed, output) = apply_fix(content);
891
892        assert_eq!(fixed, 2);
893        assert_eq!(output.matches("use std::fs::read_to_string;").count(), 2);
894        assert!(!output.contains("std::fs::read_to_string("));
895        let import_pos = output.find("mod inner").unwrap();
896        assert!(
897            output[import_pos..].contains("use std::fs::read_to_string;"),
898            "nested module receives its own import"
899        );
900    }
901
902    #[test]
903    fn test_fix_respects_names_inherited_via_super_glob() {
904        let content = "fn read() {}\n\nmod inner {\n    use super::*;\n\n    fn f() {\n        let x = std::fs::read(\"f\");\n    }\n}\n";
905        let (fixed, output) = apply_fix(content);
906
907        assert_eq!(fixed, 0);
908        assert_eq!(output, content);
909    }
910
911    #[test]
912    fn test_fix_skips_when_descendant_uses_name_bare() {
913        let content = "fn parent_call() {\n    let a = std::fs::read(\"f\");\n}\n\nmod inner {\n    use super::*;\n\n    fn g() {\n        read(\"x\");\n    }\n}\n";
914        let (fixed, output) = apply_fix(content);
915
916        assert_eq!(fixed, 0);
917        assert_eq!(output, content);
918    }
919
920    #[test]
921    fn test_fix_allows_test_module_with_super_glob() {
922        let content = "fn top() {\n    let a = std::fs::read_to_string(\"a\");\n}\n\nmod tests {\n    use super::*;\n\n    fn t() {\n        let b = std::fs::read_to_string(\"b\");\n    }\n}\n";
923        let (fixed, output) = apply_fix(content);
924
925        assert_eq!(fixed, 2);
926        assert!(!output.contains("std::fs::read_to_string("));
927    }
928
929    #[test]
930    fn test_fix_crate_rooted_path() {
931        let content = "fn main() {\n    crate::util::helper();\n}\n";
932        let (fixed, output) = apply_fix(content);
933
934        assert_eq!(fixed, 1);
935        assert!(output.contains("use crate::util::helper;"));
936        assert!(output.contains("    helper();"));
937    }
938
939    #[test]
940    fn test_default_implementation() {
941        let analyzer = PathImportAnalyzer;
942        assert_eq!(analyzer.name(), "path_import");
943    }
944
945    #[test]
946    fn test_single_segment_path() {
947        let analyzer = PathImportAnalyzer::new();
948        let code: File = parse_quote! {
949            fn main() {
950                println!("test");
951            }
952        };
953
954        let result = analyzer.analyze(&code, "").unwrap();
955        assert_eq!(result.issues.len(), 0);
956    }
957
958    #[test]
959    fn test_core_module_functions() {
960        let analyzer = PathImportAnalyzer::new();
961        let code: File = parse_quote! {
962            fn main() {
963                let size = core::mem::size_of::<u32>();
964            }
965        };
966
967        let result = analyzer.analyze(&code, "").unwrap();
968        assert!(!result.issues.is_empty());
969    }
970
971    #[test]
972    fn test_alloc_module_functions() {
973        let analyzer = PathImportAnalyzer::new();
974        let code: File = parse_quote! {
975            fn main() {
976                let data = alloc::format::format(format_args!("test"));
977            }
978        };
979
980        let result = analyzer.analyze(&code, "").unwrap();
981        assert!(!result.issues.is_empty());
982    }
983
984    #[test]
985    fn test_two_segment_path() {
986        let analyzer = PathImportAnalyzer::new();
987        let code: File = parse_quote! {
988            fn main() {
989                let x = fs::read("file");
990            }
991        };
992
993        let result = analyzer.analyze(&code, "").unwrap();
994        assert_eq!(result.issues.len(), 0);
995    }
996
997    #[test]
998    fn test_screaming_snake_case_constant() {
999        let analyzer = PathImportAnalyzer::new();
1000        let code: File = parse_quote! {
1001            fn main() {
1002                let x = std::u32::MAX_VALUE;
1003            }
1004        };
1005
1006        let result = analyzer.analyze(&code, "").unwrap();
1007        assert_eq!(result.issues.len(), 0);
1008    }
1009
1010    #[test]
1011    fn test_result_fixable_count() {
1012        let analyzer = PathImportAnalyzer::new();
1013        let code: File = parse_quote! {
1014            fn main() {
1015                let a = std::fs::read_to_string("f");
1016                let b = std::io::stdin();
1017            }
1018        };
1019
1020        let result = analyzer.analyze(&code, "").unwrap();
1021        assert_eq!(result.fixable_count, result.issues.len());
1022    }
1023
1024    #[test]
1025    fn test_issue_format() {
1026        let analyzer = PathImportAnalyzer::new();
1027        let code: File = parse_quote! {
1028            fn main() {
1029                let x = std::fs::read("file");
1030            }
1031        };
1032
1033        let result = analyzer.analyze(&code, "").unwrap();
1034        assert!(!result.issues.is_empty());
1035        let issue = &result.issues[0];
1036        assert!(
1037            issue
1038                .diagnostic
1039                .message
1040                .contains("Use import instead of path")
1041        );
1042        assert!(issue.fix.is_available());
1043        if let Some((import, pattern, replacement)) = issue.fix.as_import() {
1044            assert!(import.contains("use"));
1045            assert_eq!(pattern, "std::fs::read");
1046            assert_eq!(replacement, "read");
1047        } else {
1048            panic!("Expected Fix::WithImport");
1049        }
1050    }
1051}