1use regex::Regex;
45use serde::{Deserialize, Serialize};
46use std::collections::{BTreeMap, BTreeSet};
47use std::path::Path;
48use std::sync::LazyLock;
49
50static USE_STATEMENT_RE: LazyLock<Result<Regex, String>> = LazyLock::new(|| {
51 Regex::new(r"^\s*use\s+([A-Za-z0-9_:]+)(?:\s+qw\(([^)]*)\))?\s*;")
52 .map_err(|err| err.to_string())
53});
54
55static STRING_LITERAL_RE: LazyLock<Result<Regex, String>> =
56 LazyLock::new(|| Regex::new("'[^']*'|\"[^\"]*\"").map_err(|err| err.to_string()));
57
58static REGEX_LITERAL_RE: LazyLock<Result<Regex, String>> =
59 LazyLock::new(|| Regex::new(r"qr/[^/]*/").map_err(|err| err.to_string()));
60
61static COMMENT_RE: LazyLock<Result<Regex, String>> =
62 LazyLock::new(|| Regex::new(r"(?m)#.*$").map_err(|err| err.to_string()));
63
64static MODULE_USAGE_RE: LazyLock<Result<Regex, String>> = LazyLock::new(|| {
65 Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)::([A-Za-z_][A-Za-z0-9_]*)")
66 .map_err(|err| err.to_string())
67});
68
69#[derive(Debug, Clone)]
74pub struct TextEdit {
75 pub range: (usize, usize),
77 pub new_text: String,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
83pub struct ImportAnalysis {
84 pub unused_imports: Vec<UnusedImport>,
86 pub missing_imports: Vec<MissingImport>,
88 pub duplicate_imports: Vec<DuplicateImport>,
90 pub organization_suggestions: Vec<OrganizationSuggestion>,
92 pub imports: Vec<ImportEntry>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct UnusedImport {
99 pub module: String,
101 pub symbols: Vec<String>,
103 pub line: usize,
105 pub reason: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct MissingImport {
112 pub module: String,
114 pub symbols: Vec<String>,
116 pub suggested_location: usize,
118 pub confidence: f32,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct DuplicateImport {
125 pub module: String,
127 pub lines: Vec<usize>,
129 pub can_merge: bool,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct OrganizationSuggestion {
136 pub description: String,
138 pub priority: SuggestionPriority,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ImportEntry {
145 pub module: String,
147 pub symbols: Vec<String>,
149 pub line: usize,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155pub enum SuggestionPriority {
156 High,
158 Medium,
160 Low,
162}
163
164pub struct ImportOptimizer;
172
173fn is_pragma_module(module: &str) -> bool {
175 matches!(
176 module,
177 "strict"
178 | "warnings"
179 | "utf8"
180 | "bytes"
181 | "locale"
182 | "integer"
183 | "less"
184 | "sigtrap"
185 | "subs"
186 | "vars"
187 | "feature"
188 | "autodie"
189 | "autouse"
190 | "base"
191 | "parent"
192 | "lib"
193 | "bigint"
194 | "bignum"
195 | "bigrat"
196 )
197}
198
199fn is_perl_identifier_char(ch: char) -> bool {
200 ch == '_' || ch.is_ascii_alphanumeric()
201}
202
203fn has_identifier_boundary_before(content: &str, start: usize) -> bool {
204 start == 0 || content[..start].chars().next_back().is_none_or(|ch| !is_perl_identifier_char(ch))
205}
206
207fn has_identifier_boundary_after(content: &str, end: usize) -> bool {
208 end == content.len()
209 || content[end..].chars().next().is_none_or(|ch| !is_perl_identifier_char(ch))
210}
211
212fn contains_perl_identifier(content: &str, needle: &str) -> bool {
213 if needle.is_empty() {
214 return false;
215 }
216
217 content.match_indices(needle).any(|(start, matched)| {
218 has_identifier_boundary_before(content, start)
219 && has_identifier_boundary_after(content, start + matched.len())
220 })
221}
222
223fn get_known_module_exports(module: &str) -> Option<Vec<&'static str>> {
225 match module {
226 "Data::Dumper" => Some(vec!["Dumper"]),
227 "JSON" => Some(vec!["encode_json", "decode_json", "to_json", "from_json"]),
228 "YAML" => Some(vec!["Load", "Dump", "LoadFile", "DumpFile"]),
229 "Storable" => Some(vec!["store", "retrieve", "freeze", "thaw"]),
230 "List::Util" => Some(vec!["first", "max", "min", "sum", "reduce", "shuffle", "uniq"]),
231 "Scalar::Util" => Some(vec!["blessed", "reftype", "looks_like_number", "weaken"]),
232 "File::Spec" => Some(vec!["catfile", "catdir", "splitpath", "splitdir"]),
233 "File::Basename" => Some(vec!["basename", "dirname", "fileparse"]),
234 "Cwd" => Some(vec!["getcwd", "abs_path", "realpath"]),
235 "Time::HiRes" => Some(vec!["time", "sleep", "usleep", "gettimeofday"]),
236 "Digest::MD5" => Some(vec!["md5", "md5_hex", "md5_base64"]),
237 "MIME::Base64" => Some(vec!["encode_base64", "decode_base64"]),
238 "URI::Escape" => Some(vec!["uri_escape", "uri_unescape"]),
239 "LWP::Simple" => Some(vec!["get", "head", "getprint", "getstore", "mirror"]),
240 "LWP::UserAgent" => Some(vec![]),
241 "CGI" => Some(vec!["param", "header", "start_html", "end_html"]),
242 "DBI" => Some(vec![]), "strict" => Some(vec![]), "warnings" => Some(vec![]), "utf8" => Some(vec![]), _ => None,
247 }
248}
249
250fn strip_non_code_content(
251 content: &str,
252 string_literal_re: &Regex,
253 regex_literal_re: &Regex,
254 comment_re: &Regex,
255) -> String {
256 let stripped = string_literal_re.replace_all(content, " ").to_string();
257 let stripped = regex_literal_re.replace_all(&stripped, " ").to_string();
258 comment_re.replace_all(&stripped, " ").to_string()
259}
260
261impl ImportOptimizer {
262 pub fn new() -> Self {
277 Self
278 }
279
280 pub fn analyze_file(&self, file_path: &Path) -> Result<ImportAnalysis, String> {
297 let content = std::fs::read_to_string(file_path).map_err(|e| e.to_string())?;
298 self.analyze_content(&content)
299 }
300
301 pub fn analyze_content(&self, content: &str) -> Result<ImportAnalysis, String> {
319 let use_statement_re = USE_STATEMENT_RE.as_ref().map_err(|err| err.clone())?;
320 let string_literal_re = STRING_LITERAL_RE.as_ref().map_err(|err| err.clone())?;
321 let regex_literal_re = REGEX_LITERAL_RE.as_ref().map_err(|err| err.clone())?;
322 let comment_re = COMMENT_RE.as_ref().map_err(|err| err.clone())?;
323 let module_usage_re = MODULE_USAGE_RE.as_ref().map_err(|err| err.clone())?;
324
325 let mut imports = Vec::new();
326 for (idx, line) in content.lines().enumerate() {
327 if let Some(caps) = use_statement_re.captures(line) {
328 let module = caps[1].to_string();
329 let symbols_str = caps.get(2).map(|m| m.as_str()).unwrap_or("");
330 let symbols = if symbols_str.is_empty() {
331 Vec::new()
332 } else {
333 symbols_str
334 .split_whitespace()
335 .filter(|s| !s.is_empty())
336 .map(|s| s.trim_matches(|c| c == ',' || c == ';' || c == '"'))
337 .map(|s| s.to_string())
338 .collect::<Vec<_>>()
339 };
340 imports.push(ImportEntry { module, symbols, line: idx + 1 });
341 }
342 }
343
344 let mut module_to_lines: BTreeMap<String, Vec<usize>> = BTreeMap::new();
346 for imp in &imports {
347 module_to_lines.entry(imp.module.clone()).or_default().push(imp.line);
348 }
349 let duplicate_imports = module_to_lines
350 .iter()
351 .filter(|(_, lines)| lines.len() > 1)
352 .map(|(module, lines)| DuplicateImport {
353 module: module.clone(),
354 lines: lines.clone(),
355 can_merge: true,
356 })
357 .collect::<Vec<_>>();
358
359 let non_use_content = content
361 .lines()
362 .filter(|line| !line.trim_start().starts_with("use "))
363 .collect::<Vec<_>>()
364 .join("\n");
365 let non_use_content = strip_non_code_content(
366 &non_use_content,
367 string_literal_re,
368 regex_literal_re,
369 comment_re,
370 );
371
372 let mut unused_imports = Vec::new();
374 for imp in &imports {
375 let mut unused_symbols = Vec::new();
376
377 if !imp.symbols.is_empty() {
379 for sym in &imp.symbols {
380 if !contains_perl_identifier(&non_use_content, sym) {
382 unused_symbols.push(sym.clone());
383 }
384 }
385 } else {
386 if !is_pragma_module(&imp.module) {
387 let (is_known_module, known_exports) =
389 match get_known_module_exports(&imp.module) {
390 Some(exports) => (true, exports),
391 None => (false, Vec::new()),
392 };
393 let mut is_used = false;
394
395 if contains_perl_identifier(&non_use_content, &imp.module) {
397 is_used = true;
398 }
399
400 if !is_used
402 && imp.module == "Data::Dumper"
403 && contains_perl_identifier(&non_use_content, "Dumper")
404 {
405 is_used = true;
406 }
407
408 if !is_used && !known_exports.is_empty() {
410 for export in &known_exports {
411 if contains_perl_identifier(&non_use_content, export) {
412 is_used = true;
413 break;
414 }
415 }
416 }
417
418 if !is_used && is_known_module && known_exports.is_empty() {
422 unused_symbols.push("(bare import)".to_string());
423 }
424 }
425 }
426
427 if !unused_symbols.is_empty() {
429 unused_imports.push(UnusedImport {
430 module: imp.module.clone(),
431 symbols: unused_symbols,
432 line: imp.line,
433 reason: "Symbols not used in code".to_string(),
434 });
435 }
436 }
437
438 let imported_modules: BTreeSet<String> =
440 imports.iter().map(|imp| imp.module.clone()).collect();
441
442 let stripped =
444 strip_non_code_content(content, string_literal_re, regex_literal_re, comment_re);
445 let mut usage_map: BTreeMap<String, Vec<String>> = BTreeMap::new();
446 for caps in module_usage_re.captures_iter(&stripped) {
447 if let (Some(module_match), Some(symbol_match)) = (caps.get(1), caps.get(2)) {
449 let module = module_match.as_str().to_string();
450 let symbol = symbol_match.as_str().to_string();
451
452 if imported_modules.contains(&module) || is_pragma_module(&module) {
453 continue;
454 }
455
456 usage_map.entry(module).or_default().push(symbol);
457 }
458 }
459 let last_import_line = imports.iter().map(|i| i.line).max().unwrap_or(0);
460 let missing_imports = usage_map
461 .into_iter()
462 .map(|(module, mut symbols)| {
463 symbols.sort();
464 symbols.dedup();
465 MissingImport {
466 module,
467 symbols,
468 suggested_location: last_import_line + 1,
469 confidence: 0.8,
470 }
471 })
472 .collect::<Vec<_>>();
473
474 let mut organization_suggestions = Vec::new();
476
477 let module_order: Vec<String> = imports.iter().map(|i| i.module.clone()).collect();
479 let mut sorted_order = module_order.clone();
480 sorted_order.sort();
481 if module_order != sorted_order {
482 organization_suggestions.push(OrganizationSuggestion {
483 description: "Sort import statements alphabetically".to_string(),
484 priority: SuggestionPriority::Low,
485 });
486 }
487
488 if !duplicate_imports.is_empty() {
490 let modules =
491 duplicate_imports.iter().map(|d| d.module.clone()).collect::<Vec<_>>().join(", ");
492 organization_suggestions.push(OrganizationSuggestion {
493 description: format!("Remove duplicate imports for modules: {}", modules),
494 priority: SuggestionPriority::Medium,
495 });
496 }
497
498 let mut symbols_need_org = false;
500 for imp in &imports {
501 if imp.symbols.len() > 1 {
502 let mut sorted = imp.symbols.clone();
503 sorted.sort();
504 sorted.dedup();
505 if sorted != imp.symbols {
506 symbols_need_org = true;
507 break;
508 }
509 }
510 }
511 if symbols_need_org {
512 organization_suggestions.push(OrganizationSuggestion {
513 description: "Sort and deduplicate symbols within import statements".to_string(),
514 priority: SuggestionPriority::Low,
515 });
516 }
517
518 Ok(ImportAnalysis {
519 imports,
520 unused_imports,
521 missing_imports,
522 duplicate_imports,
523 organization_suggestions,
524 })
525 }
526
527 pub fn generate_optimized_imports(&self, analysis: &ImportAnalysis) -> String {
551 let mut optimized_imports = Vec::new();
552
553 let mut module_symbols: BTreeMap<String, Vec<String>> = BTreeMap::new();
555
556 let mut unused_by_module: BTreeMap<String, Vec<String>> = BTreeMap::new();
558 for unused in &analysis.unused_imports {
559 unused_by_module
560 .entry(unused.module.clone())
561 .or_default()
562 .extend(unused.symbols.clone());
563 }
564
565 for import in &analysis.imports {
567 let kept_symbols: Vec<String> = import
569 .symbols
570 .iter()
571 .filter(|sym| {
572 if let Some(unused_symbols) = unused_by_module.get(&import.module) {
573 !unused_symbols.contains(sym)
574 } else {
575 true }
577 })
578 .cloned()
579 .collect();
580
581 let entry = module_symbols.entry(import.module.clone()).or_default();
583 entry.extend(kept_symbols);
584
585 entry.sort();
587 entry.dedup();
588 }
589
590 for missing in &analysis.missing_imports {
592 let entry = module_symbols.entry(missing.module.clone()).or_default();
593 entry.extend(missing.symbols.clone());
594 entry.sort();
595 entry.dedup();
596 }
597
598 for (module, symbols) in &module_symbols {
601 let was_bare_import =
603 analysis.imports.iter().any(|imp| imp.module == *module && imp.symbols.is_empty());
604
605 if symbols.is_empty() && was_bare_import {
606 optimized_imports.push(format!("use {};", module));
608 } else if !symbols.is_empty() {
609 let symbol_list = symbols.join(" ");
611 optimized_imports.push(format!("use {} qw({});", module, symbol_list));
612 }
613 }
615
616 optimized_imports.sort();
618 optimized_imports.join("\n")
619 }
620
621 pub fn generate_edits(&self, content: &str, analysis: &ImportAnalysis) -> Vec<TextEdit> {
644 let optimized = self.generate_optimized_imports(analysis);
645
646 if analysis.imports.is_empty() {
647 if optimized.is_empty() {
648 return Vec::new();
649 }
650 let insert_line =
651 analysis.missing_imports.first().map(|m| m.suggested_location).unwrap_or(1);
652 let insert_offset = self.line_offset(content, insert_line);
653 return vec![TextEdit {
654 range: (insert_offset, insert_offset),
655 new_text: optimized + "\n",
656 }];
657 }
658
659 let first_line = analysis.imports.iter().map(|i| i.line).min().unwrap_or(1);
662 let last_line = analysis.imports.iter().map(|i| i.line).max().unwrap_or(1);
663
664 let start_offset = self.line_offset(content, first_line);
665 let end_offset = self.line_offset(content, last_line + 1);
666
667 vec![TextEdit {
668 range: (start_offset, end_offset),
669 new_text: if optimized.is_empty() { String::new() } else { optimized + "\n" },
670 }]
671 }
672
673 fn line_offset(&self, content: &str, line: usize) -> usize {
674 if line <= 1 {
675 return 0;
676 }
677
678 let mut offset = 0;
679 for (idx, segment) in content.split_inclusive('\n').enumerate() {
680 if idx + 1 >= line {
681 break;
682 }
683 offset += segment.len();
684 }
685 offset
686 }
687}
688
689impl Default for ImportOptimizer {
690 fn default() -> Self {
691 Self::new()
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use std::fs;
699 use std::path::PathBuf;
700 use tempfile::TempDir;
701
702 fn create_test_file(content: &str) -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
703 let temp_dir = TempDir::new()?;
704 let file_path = temp_dir.path().join("test.pl");
705 fs::write(&file_path, content)?;
706 Ok((temp_dir, file_path))
707 }
708
709 #[test]
710 fn test_basic_import_analysis() -> Result<(), Box<dyn std::error::Error>> {
711 let optimizer = ImportOptimizer::new();
712 let content = r#"#!/usr/bin/perl
713use strict;
714use warnings;
715use Data::Dumper;
716
717print Dumper(\@ARGV);
718"#;
719
720 let (_temp_dir, file_path) = create_test_file(content)?;
721 let analysis = optimizer.analyze_file(&file_path)?;
722
723 assert_eq!(analysis.imports.len(), 3);
724 assert_eq!(analysis.imports[0].module, "strict");
725 assert_eq!(analysis.imports[1].module, "warnings");
726 assert_eq!(analysis.imports[2].module, "Data::Dumper");
727
728 assert!(analysis.unused_imports.is_empty());
730 Ok(())
731 }
732
733 #[test]
734 fn test_unused_import_detection() -> Result<(), Box<dyn std::error::Error>> {
735 let optimizer = ImportOptimizer::new();
736 let content = r#"use strict;
737use warnings;
738use Data::Dumper; # This is not used
739use JSON; # This is not used
740
741print "Hello World\n";
742"#;
743
744 let (_temp_dir, file_path) = create_test_file(content)?;
745 let analysis = optimizer.analyze_file(&file_path)?;
746
747 assert!(analysis.unused_imports.is_empty());
750 Ok(())
751 }
752
753 #[test]
754 fn test_missing_import_detection() -> Result<(), Box<dyn std::error::Error>> {
755 let optimizer = ImportOptimizer::new();
756 let content = r#"use strict;
757use warnings;
758
759# Using JSON::encode_json without importing JSON
760my $json = JSON::encode_json({key => 'value'});
761
762# Using Data::Dumper::Dumper without importing Data::Dumper
763print Data::Dumper::Dumper(\@ARGV);
764"#;
765
766 let (_temp_dir, file_path) = create_test_file(content)?;
767 let analysis = optimizer.analyze_file(&file_path)?;
768 assert_eq!(analysis.missing_imports.len(), 2);
769 assert!(analysis.missing_imports.iter().any(|m| m.module == "JSON"));
770 assert!(analysis.missing_imports.iter().any(|m| m.module == "Data::Dumper"));
771 for m in &analysis.missing_imports {
772 assert_eq!(m.suggested_location, 3);
773 }
774 Ok(())
775 }
776
777 #[test]
778 fn test_duplicate_import_detection() -> Result<(), Box<dyn std::error::Error>> {
779 let optimizer = ImportOptimizer::new();
780 let content = r#"use strict;
781use warnings;
782use Data::Dumper;
783use JSON;
784use Data::Dumper; # Duplicate
785
786print Dumper(\@ARGV);
787"#;
788
789 let (_temp_dir, file_path) = create_test_file(content)?;
790 let analysis = optimizer.analyze_file(&file_path)?;
791
792 assert_eq!(analysis.duplicate_imports.len(), 1);
793 assert_eq!(analysis.duplicate_imports[0].module, "Data::Dumper");
794 assert_eq!(analysis.duplicate_imports[0].lines.len(), 2);
795 assert!(analysis.duplicate_imports[0].can_merge);
796 Ok(())
797 }
798
799 #[test]
800 fn test_organization_suggestions() -> Result<(), Box<dyn std::error::Error>> {
801 let optimizer = ImportOptimizer::new();
802 let content = r#"use warnings;
803use strict;
804use List::Util qw(max max min);
805use Data::Dumper;
806use Data::Dumper; # duplicate
807"#;
808
809 let (_temp_dir, file_path) = create_test_file(content)?;
810 let analysis = optimizer.analyze_file(&file_path)?;
811
812 assert!(
813 analysis
814 .organization_suggestions
815 .iter()
816 .any(|s| s.description.contains("Sort import statements"))
817 );
818 assert!(
819 analysis
820 .organization_suggestions
821 .iter()
822 .any(|s| s.description.contains("Remove duplicate imports"))
823 );
824 assert!(
825 analysis
826 .organization_suggestions
827 .iter()
828 .any(|s| s.description.contains("Sort and deduplicate symbols"))
829 );
830 Ok(())
831 }
832
833 #[test]
834 fn test_qw_import_parsing() -> Result<(), Box<dyn std::error::Error>> {
835 let optimizer = ImportOptimizer::new();
836 let content = r#"use List::Util qw(first max min sum);
837use Scalar::Util qw(blessed reftype);
838
839my @nums = (1, 2, 3, 4, 5);
840print "Max: " . max(@nums) . "\n";
841print "Sum: " . sum(@nums) . "\n";
842print "First: " . first { $_ > 3 } @nums;
843"#;
844
845 let (_temp_dir, file_path) = create_test_file(content)?;
846 let analysis = optimizer.analyze_file(&file_path)?;
847
848 assert_eq!(analysis.imports.len(), 2);
849
850 let list_util = analysis
851 .imports
852 .iter()
853 .find(|i| i.module == "List::Util")
854 .ok_or("List::Util import not found")?;
855 assert_eq!(list_util.symbols, vec!["first", "max", "min", "sum"]);
856
857 let scalar_util = analysis
858 .imports
859 .iter()
860 .find(|i| i.module == "Scalar::Util")
861 .ok_or("Scalar::Util import not found")?;
862 assert_eq!(scalar_util.symbols, vec!["blessed", "reftype"]);
863
864 assert_eq!(analysis.unused_imports.len(), 2);
866
867 let list_util_unused = analysis
868 .unused_imports
869 .iter()
870 .find(|u| u.module == "List::Util")
871 .ok_or("List::Util unused imports not found")?;
872 assert_eq!(list_util_unused.symbols, vec!["min"]);
873
874 let scalar_util_unused = analysis
875 .unused_imports
876 .iter()
877 .find(|u| u.module == "Scalar::Util")
878 .ok_or("Scalar::Util unused imports not found")?;
879 assert_eq!(scalar_util_unused.symbols, vec!["blessed", "reftype"]);
880 Ok(())
881 }
882
883 #[test]
884 fn test_generate_optimized_imports() {
885 let optimizer = ImportOptimizer::new();
886
887 let analysis = ImportAnalysis {
888 imports: vec![
889 ImportEntry { module: "strict".to_string(), symbols: vec![], line: 1 },
890 ImportEntry { module: "warnings".to_string(), symbols: vec![], line: 2 },
891 ImportEntry {
892 module: "List::Util".to_string(),
893 symbols: vec!["first".to_string(), "max".to_string(), "unused".to_string()],
894 line: 3,
895 },
896 ],
897 unused_imports: vec![UnusedImport {
898 module: "List::Util".to_string(),
899 symbols: vec!["unused".to_string()],
900 line: 3,
901 reason: "Symbol not used".to_string(),
902 }],
903 missing_imports: vec![MissingImport {
904 module: "Data::Dumper".to_string(),
905 symbols: vec!["Dumper".to_string()],
906 suggested_location: 10,
907 confidence: 0.8,
908 }],
909 duplicate_imports: vec![],
910 organization_suggestions: vec![],
911 };
912
913 let optimized = optimizer.generate_optimized_imports(&analysis);
914
915 let expected_lines = [
917 "use Data::Dumper qw(Dumper);",
918 "use List::Util qw(first max);",
919 "use strict;",
920 "use warnings;",
921 ];
922
923 assert_eq!(optimized, expected_lines.join("\n"));
924 }
925
926 #[test]
927 fn test_empty_file_analysis() -> Result<(), Box<dyn std::error::Error>> {
928 let optimizer = ImportOptimizer::new();
929 let content = "";
930
931 let (_temp_dir, file_path) = create_test_file(content)?;
932 let analysis = optimizer.analyze_file(&file_path)?;
933
934 assert!(analysis.imports.is_empty());
935 assert!(analysis.unused_imports.is_empty());
936 assert!(analysis.missing_imports.is_empty());
937 assert!(analysis.duplicate_imports.is_empty());
938 Ok(())
939 }
940
941 #[test]
942 fn test_complex_perl_code_analysis() -> Result<(), Box<dyn std::error::Error>> {
943 let optimizer = ImportOptimizer::new();
944 let content = r#"#!/usr/bin/perl
945use strict;
946use warnings;
947use Data::Dumper;
948use JSON qw(encode_json decode_json);
949use LWP::UserAgent; # Unused
950use File::Spec::Functions qw(catfile catdir);
951
952# Complex code with various patterns
953my $data = { key => 'value', numbers => [1, 2, 3] };
954my $json_string = encode_json($data);
955print "JSON: $json_string\n";
956
957# Using File::Spec but not all imported functions
958my $path = catfile('/tmp', 'test.json');
959print "Path: $path\n";
960
961# Using modules without explicit imports
962my $response = HTTP::Tiny::new()->get('http://example.com');
963print Dumper($response);
964"#;
965
966 let (_temp_dir, file_path) = create_test_file(content)?;
967 let analysis = optimizer.analyze_file(&file_path)?;
968
969 assert!(analysis.unused_imports.iter().any(|u| u.module == "LWP::UserAgent"));
971
972 let file_spec_unused =
974 analysis.unused_imports.iter().find(|u| u.module == "File::Spec::Functions");
975 if let Some(unused) = file_spec_unused {
976 assert!(unused.symbols.contains(&"catdir".to_string()));
977 }
978
979 assert!(analysis.missing_imports.iter().any(|m| m.module == "HTTP::Tiny"));
981 Ok(())
982 }
983
984 #[test]
985 fn test_bare_import_with_exports_detection() -> Result<(), Box<dyn std::error::Error>> {
986 let optimizer = ImportOptimizer::new();
987 let content = r#"use strict;
988use warnings;
989use Data::Dumper; # Used
990use JSON; # Unused - has exports but none are used
991use SomeUnknownModule; # Conservative - not marked as unused
992
993print Dumper(\@ARGV);
994"#;
995
996 let (_temp_dir, file_path) = create_test_file(content)?;
997 let analysis = optimizer.analyze_file(&file_path)?;
998
999 assert!(!analysis.unused_imports.iter().any(|u| u.module == "Data::Dumper"));
1001
1002 assert!(analysis.unused_imports.is_empty());
1005 Ok(())
1006 }
1007
1008 #[test]
1009 fn test_imported_symbol_usage_requires_identifier_boundaries()
1010 -> Result<(), Box<dyn std::error::Error>> {
1011 let optimizer = ImportOptimizer::new();
1012 let content = r#"use strict;
1013use warnings;
1014use List::Util qw(first max);
1015
1016my $first_name = 'Ada';
1017my $maximum = 42;
1018my $match = first { $_ > 10 } @values;
1019print $first_name, $maximum, $match;
1020"#;
1021
1022 let (_temp_dir, file_path) = create_test_file(content)?;
1023 let analysis = optimizer.analyze_file(&file_path)?;
1024
1025 let unused = analysis.unused_imports.iter().find(|unused| unused.module == "List::Util");
1026
1027 assert!(unused.is_some_and(|unused| unused.symbols == ["max"]));
1028 Ok(())
1029 }
1030
1031 #[test]
1032 fn test_regex_edge_cases() -> Result<(), Box<dyn std::error::Error>> {
1033 let optimizer = ImportOptimizer::new();
1034 let content = r#"use strict;
1035use warnings;
1036
1037# These should not be detected as module references
1038my $string = "This is not JSON::encode_json in a string";
1039my $regex = qr/Data::Dumper/;
1040print "Module::Name is just text";
1041
1042# This should be detected
1043my $result = JSON::encode_json({test => 1});
1044"#;
1045
1046 let (_temp_dir, file_path) = create_test_file(content)?;
1047 let analysis = optimizer.analyze_file(&file_path)?;
1048
1049 assert_eq!(analysis.missing_imports.len(), 1);
1051 assert_eq!(analysis.missing_imports[0].module, "JSON");
1052 Ok(())
1053 }
1054
1055 #[test]
1056 fn test_generate_edits_preserves_crlf_import_block_range()
1057 -> Result<(), Box<dyn std::error::Error>> {
1058 let optimizer = ImportOptimizer::new();
1059 let content = concat!("use warnings;\r\n", "use strict;\r\n", "print qq(done);\r\n");
1060 let expected_range_end = concat!("use warnings;\r\n", "use strict;\r\n").len();
1061 let analysis = optimizer.analyze_content(content)?;
1062
1063 let edits = optimizer.generate_edits(content, &analysis);
1064 assert_eq!(edits.len(), 1);
1065 assert_eq!(edits[0].range, (0, expected_range_end));
1066 assert_eq!(edits[0].new_text, "use strict;\nuse warnings;\n");
1067 Ok(())
1068 }
1069
1070 #[test]
1071 fn test_malformed_regex_capture_safety() -> Result<(), Box<dyn std::error::Error>> {
1072 let optimizer = ImportOptimizer::new();
1073 let content = r#"use strict;
1075use warnings;
1076
1077# Normal module usage
1078my $result = JSON::encode_json({test => 1});
1079
1080# Edge case patterns that might not fully match the regex
1081my $incomplete = "Something::";
1082my $partial = "::Function";
1083"#;
1084
1085 let (_temp_dir, file_path) = create_test_file(content)?;
1086 let analysis = optimizer.analyze_file(&file_path)?;
1088
1089 assert_eq!(analysis.missing_imports.len(), 1);
1091 assert_eq!(analysis.missing_imports[0].module, "JSON");
1092 Ok(())
1093 }
1094}