1use 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
26pub struct PathImportAnalyzer;
44
45impl PathImportAnalyzer {
46 #[inline]
48 pub fn new() -> Self {
49 Self
50 }
51
52 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 fn is_screaming_snake_case(s: &str) -> bool {
137 s.chars()
138 .all(|c| c.is_uppercase() || c == '_' || c.is_numeric())
139 }
140
141 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
205fn 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
222struct 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
291struct 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}