1use 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
20pub struct PathImportAnalyzer;
38
39impl PathImportAnalyzer {
40 #[inline]
42 pub fn new() -> Self {
43 Self
44 }
45
46 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 fn is_screaming_snake_case(s: &str) -> bool {
131 s.chars()
132 .all(|c| c.is_uppercase() || c == '_' || c.is_numeric())
133 }
134
135 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 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
210fn 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
227struct 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
296struct 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}