1use super::import_optimizer::ImportOptimizer;
54use perl_module::path::module_name_to_path;
55use perl_workspace::workspace_index::{
56 SymKind, SymbolKey, WorkspaceIndex, fs_path_to_uri, normalize_var, uri_to_fs_path,
57};
58use regex::Regex;
59use serde::{Deserialize, Serialize};
60use std::collections::BTreeMap;
61use std::fmt;
62use std::path::{Path, PathBuf};
63use std::sync::{Arc, OnceLock};
64
65#[derive(Debug, Clone)]
67pub enum RefactorError {
68 UriConversion(String),
70 DocumentNotIndexed(String),
72 InvalidPosition {
74 file: String,
76 details: String,
78 },
79 SymbolNotFound {
81 symbol: String,
83 file: String,
85 },
86 ParseError(String),
88 InvalidInput(String),
90 FileSystemError(String),
92}
93
94impl fmt::Display for RefactorError {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 match self {
97 RefactorError::UriConversion(msg) => write!(f, "URI conversion failed: {}", msg),
98 RefactorError::DocumentNotIndexed(file) => {
99 write!(f, "Document not indexed in workspace: {}", file)
100 }
101 RefactorError::InvalidPosition { file, details } => {
102 write!(f, "Invalid position in {}: {}", file, details)
103 }
104 RefactorError::SymbolNotFound { symbol, file } => {
105 write!(f, "Symbol '{}' not found in {}", symbol, file)
106 }
107 RefactorError::ParseError(msg) => write!(f, "Parse error: {}", msg),
108 RefactorError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
109 RefactorError::FileSystemError(msg) => write!(f, "File system error: {}", msg),
110 }
111 }
112}
113
114impl std::error::Error for RefactorError {}
115
116static IMPORT_BLOCK_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
118
119fn get_import_block_regex() -> Option<&'static Regex> {
121 IMPORT_BLOCK_RE.get_or_init(|| Regex::new(r"(?m)^(?:use\s+[\w:]+[^\n]*\n)+")).as_ref().ok()
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct FileEdit {
132 pub file_path: PathBuf,
134 pub edits: Vec<TextEdit>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct TextEdit {
145 pub start: usize,
147 pub end: usize,
149 pub new_text: String,
151}
152
153#[derive(Debug, Serialize, Deserialize)]
159pub struct RefactorResult {
160 pub file_edits: Vec<FileEdit>,
162 pub description: String,
164 pub warnings: Vec<String>,
166}
167
168pub struct WorkspaceRefactor {
187 pub _index: WorkspaceIndex,
189}
190
191impl WorkspaceRefactor {
192 pub fn new(index: WorkspaceIndex) -> Self {
200 Self { _index: index }
201 }
202
203 pub fn rename_symbol(
236 &self,
237 old_name: &str,
238 new_name: &str,
239 _file_path: &Path,
240 _position: (usize, usize),
241 ) -> Result<RefactorResult, RefactorError> {
242 if old_name.is_empty() {
244 return Err(RefactorError::InvalidInput("Symbol name cannot be empty".to_string()));
245 }
246 if new_name.is_empty() {
247 return Err(RefactorError::InvalidInput("New name cannot be empty".to_string()));
248 }
249 if old_name == new_name {
250 return Err(RefactorError::InvalidInput("Old and new names are identical".to_string()));
251 }
252
253 let (sigil, bare) = normalize_var(old_name);
255 let kind = if sigil.is_some() { SymKind::Var } else { SymKind::Sub };
256
257 let key = SymbolKey {
259 pkg: Arc::from("main".to_string()),
260 name: Arc::from(bare.to_string()),
261 sigil,
262 kind,
263 };
264
265 let mut edits: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
266
267 let mut locations = self._index.find_refs(&key);
269
270 let def_loc = self._index.find_def(&key);
272 if let Some(def) = def_loc {
273 if !locations.iter().any(|loc| loc.uri == def.uri && loc.range == def.range) {
274 locations.push(def);
275 }
276 }
277
278 let store = self._index.document_store();
279
280 if locations.is_empty() {
281 let _old_name_bytes = old_name.as_bytes();
283
284 for doc in store.all_documents() {
285 if !doc.text.contains(old_name) {
287 continue;
288 }
289
290 let idx = doc.line_index.clone();
291 let mut pos = 0;
292 let _text_bytes = doc.text.as_bytes();
293
294 while let Some(found) = doc.text[pos..].find(old_name) {
296 let start = pos + found;
297 let end = start + old_name.len();
298
299 if start >= doc.text.len() || end > doc.text.len() {
301 break;
302 }
303
304 let (start_line, start_col) = idx.offset_to_position(start);
305 let (end_line, end_col) = idx.offset_to_position(end);
306 let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
307 let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
308 locations.push(perl_workspace::workspace_index::Location {
309 uri: doc.uri.clone(),
310 range: perl_parser_core::position::Range {
311 start: perl_parser_core::position::Position {
312 byte: start_byte,
313 line: start_line,
314 column: start_col,
315 },
316 end: perl_parser_core::position::Position {
317 byte: end_byte,
318 line: end_line,
319 column: end_col,
320 },
321 },
322 });
323 pos = end;
324
325 if locations.len() >= 1000 {
327 break;
328 }
329 }
330
331 if locations.len() >= 500 {
334 break;
335 }
336 }
337 }
338
339 for loc in locations {
340 let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
341 RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
342 })?;
343 if let Some(doc) = store.get(&loc.uri) {
344 let start_off =
345 doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
346 let end_off =
347 doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
348 if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
349 let replacement = match kind {
350 SymKind::Var => {
351 let sig = sigil.unwrap_or('$');
352 format!("{}{}", sig, new_name.trim_start_matches(['$', '@', '%']))
353 }
354 _ => new_name.to_string(),
355 };
356 edits.entry(path).or_default().push(TextEdit {
357 start: start_off,
358 end: end_off,
359 new_text: replacement,
360 });
361 }
362 }
363 }
364
365 let file_edits: Vec<FileEdit> =
366 edits.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
367
368 let description = format!("Rename '{}' to '{}'", old_name, new_name);
369 Ok(RefactorResult { file_edits, description, warnings: vec![] })
370 }
371
372 pub fn extract_module(
411 &self,
412 file_path: &Path,
413 start_line: usize,
414 end_line: usize,
415 module_name: &str,
416 ) -> Result<RefactorResult, RefactorError> {
417 if module_name.is_empty() {
419 return Err(RefactorError::InvalidInput("Module name cannot be empty".to_string()));
420 }
421 if start_line > end_line {
422 return Err(RefactorError::InvalidInput(
423 "Start line cannot be after end line".to_string(),
424 ));
425 }
426
427 let uri = fs_path_to_uri(file_path).map_err(|e| {
428 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
429 })?;
430 let store = self._index.document_store();
431 let doc = store
432 .get(&uri)
433 .ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
434 let idx = doc.line_index.clone();
435
436 let start_off = idx.position_to_offset(start_line as u32 - 1, 0).ok_or_else(|| {
438 RefactorError::InvalidPosition {
439 file: file_path.display().to_string(),
440 details: format!("Invalid start line: {}", start_line),
441 }
442 })?;
443 let end_off = idx.position_to_offset(end_line as u32, 0).unwrap_or(doc.text.len());
444
445 let extracted = doc.text[start_off..end_off].to_string();
446
447 let original_edits = vec![TextEdit {
449 start: start_off,
450 end: end_off,
451 new_text: format!("use {};\n", module_name),
452 }];
453
454 let new_path = file_path.with_file_name(module_name_to_path(module_name));
456 let new_edits = vec![TextEdit { start: 0, end: 0, new_text: extracted }];
457
458 let file_edits = vec![
459 FileEdit { file_path: file_path.to_path_buf(), edits: original_edits },
460 FileEdit { file_path: new_path.clone(), edits: new_edits },
461 ];
462
463 Ok(RefactorResult {
464 file_edits,
465 description: format!(
466 "Extract {} lines from {} into module '{}'",
467 end_line - start_line + 1,
468 file_path.display(),
469 module_name
470 ),
471 warnings: vec![],
472 })
473 }
474
475 pub fn optimize_imports(&self) -> Result<RefactorResult, String> {
488 let optimizer = ImportOptimizer::new();
489 let mut file_edits = Vec::new();
490
491 for doc in self._index.document_store().all_documents() {
493 let Some(path) = uri_to_fs_path(&doc.uri) else { continue };
494
495 let analysis = optimizer.analyze_content(&doc.text)?;
496 let optimized = optimizer.generate_optimized_imports(&analysis);
497
498 if optimized.is_empty() {
499 continue;
500 }
501
502 let (start, end) = if let Some(import_block_re) = get_import_block_regex() {
504 if let Some(m) = import_block_re.find(&doc.text) {
505 (m.start(), m.end())
506 } else {
507 (0, 0)
508 }
509 } else {
510 (0, 0)
512 };
513
514 file_edits.push(FileEdit {
515 file_path: path.clone(),
516 edits: vec![TextEdit { start, end, new_text: format!("{}\n", optimized) }],
517 });
518 }
519
520 Ok(RefactorResult {
521 file_edits,
522 description: "Optimize imports across workspace".to_string(),
523 warnings: vec![],
524 })
525 }
526
527 pub fn move_subroutine(
566 &self,
567 sub_name: &str,
568 from_file: &Path,
569 to_module: &str,
570 ) -> Result<RefactorResult, RefactorError> {
571 if sub_name.is_empty() {
573 return Err(RefactorError::InvalidInput("Subroutine name cannot be empty".to_string()));
574 }
575 if to_module.is_empty() {
576 return Err(RefactorError::InvalidInput(
577 "Target module name cannot be empty".to_string(),
578 ));
579 }
580
581 let uri = fs_path_to_uri(from_file).map_err(|e| {
582 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
583 })?;
584 let symbols = self._index.file_symbols(&uri);
585 let sym = symbols.into_iter().find(|s| s.name == sub_name).ok_or_else(|| {
586 RefactorError::SymbolNotFound {
587 symbol: sub_name.to_string(),
588 file: from_file.display().to_string(),
589 }
590 })?;
591
592 let store = self._index.document_store();
593 let doc = store
594 .get(&uri)
595 .ok_or_else(|| RefactorError::DocumentNotIndexed(from_file.display().to_string()))?;
596 let idx = doc.line_index.clone();
597 let start_off = idx
598 .position_to_offset(sym.range.start.line, sym.range.start.column)
599 .ok_or_else(|| RefactorError::InvalidPosition {
600 file: from_file.display().to_string(),
601 details: format!(
602 "Invalid start position for subroutine '{}' at line {}, column {}",
603 sub_name, sym.range.start.line, sym.range.start.column
604 ),
605 })?;
606 let end_off =
607 idx.position_to_offset(sym.range.end.line, sym.range.end.column).ok_or_else(|| {
608 RefactorError::InvalidPosition {
609 file: from_file.display().to_string(),
610 details: format!(
611 "Invalid end position for subroutine '{}' at line {}, column {}",
612 sub_name, sym.range.end.line, sym.range.end.column
613 ),
614 }
615 })?;
616 let sub_text = doc.text[start_off..end_off].to_string();
617
618 let mut file_edits = vec![FileEdit {
620 file_path: from_file.to_path_buf(),
621 edits: vec![TextEdit { start: start_off, end: end_off, new_text: String::new() }],
622 }];
623
624 let target_path = from_file.with_file_name(module_name_to_path(to_module));
626 let target_uri = fs_path_to_uri(&target_path).map_err(|e| {
627 RefactorError::UriConversion(format!("Failed to convert target path to URI: {}", e))
628 })?;
629 let target_doc = store.get(&target_uri);
630 let insertion_offset = target_doc.as_ref().map(|d| d.text.len()).unwrap_or(0);
631
632 file_edits.push(FileEdit {
633 file_path: target_path.clone(),
634 edits: vec![TextEdit {
635 start: insertion_offset,
636 end: insertion_offset,
637 new_text: sub_text,
638 }],
639 });
640
641 Ok(RefactorResult {
642 file_edits,
643 description: format!(
644 "Move subroutine '{}' from {} to module '{}'",
645 sub_name,
646 from_file.display(),
647 to_module
648 ),
649 warnings: vec![],
650 })
651 }
652
653 pub fn inline_variable(
691 &self,
692 var_name: &str,
693 file_path: &Path,
694 _position: (usize, usize),
695 ) -> Result<RefactorResult, RefactorError> {
696 let (sigil, bare) = normalize_var(var_name);
697 let _key = SymbolKey {
698 pkg: Arc::from("main".to_string()),
699 name: Arc::from(bare.to_string()),
700 sigil,
701 kind: SymKind::Var,
702 };
703
704 if var_name.is_empty() {
706 return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
707 }
708
709 let uri = fs_path_to_uri(file_path).map_err(|e| {
710 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
711 })?;
712 let store = self._index.document_store();
713 let doc = store
714 .get(&uri)
715 .ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
716 let idx = doc.line_index.clone();
717
718 let def_line_idx = doc
720 .text
721 .lines()
722 .position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
723 .ok_or_else(|| RefactorError::SymbolNotFound {
724 symbol: var_name.to_string(),
725 file: file_path.display().to_string(),
726 })?;
727 let def_line_start = idx.position_to_offset(def_line_idx as u32, 0).ok_or_else(|| {
728 RefactorError::InvalidPosition {
729 file: file_path.display().to_string(),
730 details: format!("Invalid start position for definition line: {}", def_line_idx),
731 }
732 })?;
733 let def_line_end =
734 idx.position_to_offset(def_line_idx as u32 + 1, 0).unwrap_or(doc.text.len());
735 let def_line = doc.text.lines().nth(def_line_idx).unwrap_or("");
736 let expr = def_line
737 .split('=')
738 .nth(1)
739 .map(|s| s.trim().trim_end_matches(';'))
740 .ok_or_else(|| {
741 RefactorError::ParseError(format!(
742 "Variable '{}' has no initializer in line: {}",
743 var_name, def_line
744 ))
745 })?
746 .to_string();
747
748 let mut edits_map: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
749
750 edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
752 start: def_line_start,
753 end: def_line_end,
754 new_text: String::new(),
755 });
756
757 let mut search_pos = def_line_end;
759 while let Some(found) = doc.text[search_pos..].find(var_name) {
760 let start = search_pos + found;
761 let end = start + var_name.len();
762 edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
763 start,
764 end,
765 new_text: expr.clone(),
766 });
767 search_pos = end;
768 }
769
770 let file_edits =
771 edits_map.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
772
773 Ok(RefactorResult {
774 file_edits,
775 description: format!("Inline variable '{}' in {}", var_name, file_path.display()),
776 warnings: vec![],
777 })
778 }
779
780 pub fn inline_variable_all(
793 &self,
794 var_name: &str,
795 def_file_path: &Path,
796 _position: (usize, usize),
797 ) -> Result<RefactorResult, RefactorError> {
798 if var_name.is_empty() {
799 return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
800 }
801
802 let def_uri = fs_path_to_uri(def_file_path).map_err(|e| {
803 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
804 })?;
805 let store = self._index.document_store();
806 let def_doc = store.get(&def_uri).ok_or_else(|| {
807 RefactorError::DocumentNotIndexed(def_file_path.display().to_string())
808 })?;
809
810 let (sigil, bare) = normalize_var(var_name);
811
812 let def_line_idx = def_doc
813 .text
814 .lines()
815 .position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
816 .ok_or_else(|| RefactorError::SymbolNotFound {
817 symbol: var_name.to_string(),
818 file: def_file_path.display().to_string(),
819 })?;
820
821 let def_line_byte_offset =
825 def_doc.text.lines().take(def_line_idx).map(|l| l.len() + 1).sum::<usize>();
826 let package_name = find_package_at_offset(&def_doc.text, def_line_byte_offset)
827 .unwrap_or_else(|| "main".to_string());
828
829 let key = SymbolKey {
830 pkg: Arc::from(package_name.clone()),
831 name: Arc::from(bare.to_string()),
832 sigil,
833 kind: SymKind::Var,
834 };
835
836 let def_line = def_doc.text.lines().nth(def_line_idx).unwrap_or("");
837
838 let expr = def_line
839 .split('=')
840 .nth(1)
841 .map(|s| s.trim().trim_end_matches(';'))
842 .ok_or_else(|| {
843 RefactorError::ParseError(format!(
844 "Variable '{}' has no initializer in line: {}",
845 var_name, def_line
846 ))
847 })?
848 .to_string();
849
850 let mut warnings = Vec::new();
851
852 if expr.contains('(') && expr.contains(')') {
853 warnings.push(format!(
854 "Warning: Initializer '{}' may contain function calls or side effects",
855 expr
856 ));
857 }
858
859 let mut all_locations = self._index.find_refs(&key);
860
861 if let Some(def_loc) = self._index.find_def(&key) {
862 if !all_locations.iter().any(|loc| loc.uri == def_loc.uri && loc.range == def_loc.range)
863 {
864 all_locations.push(def_loc);
865 }
866 }
867
868 if all_locations.is_empty() {
869 for doc in store.all_documents() {
870 if !doc.text.contains(var_name) {
871 continue;
872 }
873 if !is_document_in_package_scope(&doc.text, &package_name) {
874 continue;
875 }
876
877 let idx = doc.line_index.clone();
878 let mut pos = 0;
879
880 while let Some(found) = doc.text[pos..].find(var_name) {
881 let start = pos + found;
882 let end = start + var_name.len();
883
884 if start >= doc.text.len() || end > doc.text.len() {
885 break;
886 }
887
888 let (start_line, start_col) = idx.offset_to_position(start);
889 let (end_line, end_col) = idx.offset_to_position(end);
890 let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
891 let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
892
893 all_locations.push(perl_workspace::workspace_index::Location {
894 uri: doc.uri.clone(),
895 range: perl_parser_core::position::Range {
896 start: perl_parser_core::position::Position {
897 byte: start_byte,
898 line: start_line,
899 column: start_col,
900 },
901 end: perl_parser_core::position::Position {
902 byte: end_byte,
903 line: end_line,
904 column: end_col,
905 },
906 },
907 });
908 pos = end;
909
910 if all_locations.len() >= 1000 {
911 warnings.push(
912 "Warning: More than 1000 occurrences found, limiting results"
913 .to_string(),
914 );
915 break;
916 }
917 }
918
919 if all_locations.len() >= 1000 {
920 break;
921 }
922 }
923 }
924
925 let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
926 let mut total_occurrences = 0;
927 let mut files_affected = std::collections::HashSet::new();
928
929 for loc in all_locations {
930 let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
931 RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
932 })?;
933
934 if let Some(doc) = store.get(&loc.uri) {
935 let start_off =
936 doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
937 let location_package =
938 start_off.and_then(|offset| find_package_at_offset(&doc.text, offset));
939 if location_package.as_deref().unwrap_or("main") != package_name {
940 continue;
941 }
942
943 files_affected.insert(path.clone());
944
945 let start_off =
946 doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
947 let end_off =
948 doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
949
950 if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
951 let is_definition = doc.uri == def_uri
952 && doc.text[start_off.saturating_sub(10)..start_off.min(doc.text.len())]
953 .contains("my ");
954
955 if is_definition {
956 let line_start =
957 doc.text[..start_off].rfind('\n').map(|p| p + 1).unwrap_or(0);
958 let line_end = doc.text[end_off..]
959 .find('\n')
960 .map(|p| end_off + p + 1)
961 .unwrap_or(doc.text.len());
962
963 edits_by_file.entry(path).or_default().push(TextEdit {
964 start: line_start,
965 end: line_end,
966 new_text: String::new(),
967 });
968 } else {
969 edits_by_file.entry(path).or_default().push(TextEdit {
970 start: start_off,
971 end: end_off,
972 new_text: expr.clone(),
973 });
974 total_occurrences += 1;
975 }
976 }
977 }
978 }
979
980 let file_edits: Vec<FileEdit> = edits_by_file
981 .into_iter()
982 .map(|(file_path, edits)| FileEdit { file_path, edits })
983 .collect();
984
985 let description = format!(
986 "Inline variable '{}' across workspace: {} occurrences in {} files",
987 var_name,
988 total_occurrences,
989 files_affected.len()
990 );
991
992 Ok(RefactorResult { file_edits, description, warnings })
993 }
994}
995
996fn find_package_declaration(text: &str) -> Option<String> {
997 text.lines().find_map(|line| {
998 let trimmed = line.trim_start();
999 if !trimmed.starts_with("package ") {
1000 return None;
1001 }
1002
1003 let package_part = trimmed.strip_prefix("package ")?;
1004 let end_idx = package_part
1005 .char_indices()
1006 .find_map(|(idx, ch)| (ch == ';' || ch == '{' || ch.is_whitespace()).then_some(idx))
1007 .unwrap_or(package_part.len());
1008 let package_name = &package_part[..end_idx];
1009
1010 (!package_name.is_empty()).then(|| package_name.to_string())
1011 })
1012}
1013
1014fn is_document_in_package_scope(text: &str, package_name: &str) -> bool {
1015 find_package_declaration(text).as_deref().unwrap_or("main") == package_name
1016}
1017
1018fn find_package_at_offset(text: &str, offset: usize) -> Option<String> {
1019 let before = &text[..offset.min(text.len())];
1020 let mut package = None;
1021 let mut search_pos = 0usize;
1022
1023 while let Some(found) = before[search_pos..].find("package ") {
1024 let package_start = search_pos + found + "package ".len();
1025 let rest = &before[package_start..];
1026 let package_end = rest
1027 .char_indices()
1028 .find_map(|(idx, ch)| (ch == ';' || ch == '{' || ch.is_whitespace()).then_some(idx))
1029 .unwrap_or(rest.len());
1030 let candidate = &rest[..package_end];
1031 if !candidate.is_empty() {
1032 package = Some(candidate.to_string());
1033 }
1034 search_pos = package_start;
1035 }
1036
1037 package
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043 use tempfile::{TempDir, tempdir};
1044
1045 fn setup_index(
1046 files: Vec<(&str, &str)>,
1047 ) -> Result<(TempDir, WorkspaceIndex, Vec<PathBuf>), Box<dyn std::error::Error>> {
1048 let dir = tempdir()?;
1049 let mut paths = Vec::new();
1050 let index = WorkspaceIndex::new();
1051 for (name, content) in files {
1052 let path = dir.path().join(name);
1053 std::fs::write(&path, content)?;
1054 let path_str = path.to_str().ok_or_else(|| {
1055 format!("Failed to convert path to string for test file: {}", name)
1056 })?;
1057 index.index_file_str(path_str, content)?;
1058 paths.push(path);
1059 }
1060 Ok((dir, index, paths))
1061 }
1062
1063 #[test]
1064 fn test_rename_symbol() -> Result<(), Box<dyn std::error::Error>> {
1065 let (_dir, index, paths) =
1066 setup_index(vec![("a.pl", "my $foo = 1; print $foo;"), ("b.pl", "print $foo;")])?;
1067 let refactor = WorkspaceRefactor::new(index);
1068 let result = refactor.rename_symbol("$foo", "$bar", &paths[0], (0, 0))?;
1069 assert!(!result.file_edits.is_empty());
1070 Ok(())
1071 }
1072
1073 #[test]
1074 fn test_extract_module() -> Result<(), Box<dyn std::error::Error>> {
1075 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1076 let refactor = WorkspaceRefactor::new(index);
1077 let res = refactor.extract_module(&paths[0], 2, 2, "Extracted")?;
1078 assert_eq!(res.file_edits.len(), 2);
1079 Ok(())
1080 }
1081
1082 #[test]
1083 fn test_extract_module_qualified_name_uses_nested_path()
1084 -> Result<(), Box<dyn std::error::Error>> {
1085 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1086 let refactor = WorkspaceRefactor::new(index);
1087 let res = refactor.extract_module(&paths[0], 2, 2, "My::Extracted")?;
1088 assert_eq!(res.file_edits.len(), 2);
1089 assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("My/Extracted.pm"));
1090 Ok(())
1091 }
1092
1093 #[test]
1094 fn test_optimize_imports() -> Result<(), Box<dyn std::error::Error>> {
1095 let (_dir, index, _paths) = setup_index(vec![
1096 ("a.pl", "use B;\nuse A;\nuse B;\n"),
1097 ("b.pl", "use C;\nuse A;\nuse C;\n"),
1098 ])?;
1099 let refactor = WorkspaceRefactor::new(index);
1100 let res = refactor.optimize_imports()?;
1101 assert_eq!(res.file_edits.len(), 2);
1102 Ok(())
1103 }
1104
1105 #[test]
1106 fn test_move_subroutine() -> Result<(), Box<dyn std::error::Error>> {
1107 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1108 let refactor = WorkspaceRefactor::new(index);
1109 let res = refactor.move_subroutine("foo", &paths[0], "b")?;
1110 assert_eq!(res.file_edits.len(), 2);
1111 Ok(())
1112 }
1113
1114 #[test]
1115 fn test_move_subroutine_qualified_target_uses_nested_path()
1116 -> Result<(), Box<dyn std::error::Error>> {
1117 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1118 let refactor = WorkspaceRefactor::new(index);
1119 let res = refactor.move_subroutine("foo", &paths[0], "Target::Module")?;
1120 assert_eq!(res.file_edits.len(), 2);
1121 assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("Target/Module.pm"));
1122 Ok(())
1123 }
1124
1125 #[test]
1126 fn test_inline_variable() -> Result<(), Box<dyn std::error::Error>> {
1127 let (_dir, index, paths) =
1128 setup_index(vec![("a.pl", "my $x = 42;\nmy $y = $x + 1;\nprint $y;\n")])?;
1129 let refactor = WorkspaceRefactor::new(index);
1130 let result = refactor.inline_variable("$x", &paths[0], (0, 0))?;
1131 assert!(!result.file_edits.is_empty());
1132 Ok(())
1133 }
1134
1135 #[test]
1137 fn test_rename_symbol_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1138 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $foo = 1;")])?;
1139 let refactor = WorkspaceRefactor::new(index);
1140
1141 assert!(matches!(
1143 refactor.rename_symbol("", "$bar", &paths[0], (0, 0)),
1144 Err(RefactorError::InvalidInput(_))
1145 ));
1146
1147 assert!(matches!(
1149 refactor.rename_symbol("$foo", "", &paths[0], (0, 0)),
1150 Err(RefactorError::InvalidInput(_))
1151 ));
1152
1153 assert!(matches!(
1155 refactor.rename_symbol("$foo", "$foo", &paths[0], (0, 0)),
1156 Err(RefactorError::InvalidInput(_))
1157 ));
1158 Ok(())
1159 }
1160
1161 #[test]
1162 fn test_extract_module_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1163 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1164 let refactor = WorkspaceRefactor::new(index);
1165
1166 assert!(matches!(
1168 refactor.extract_module(&paths[0], 1, 2, ""),
1169 Err(RefactorError::InvalidInput(_))
1170 ));
1171
1172 assert!(matches!(
1174 refactor.extract_module(&paths[0], 5, 2, "Test"),
1175 Err(RefactorError::InvalidInput(_))
1176 ));
1177 Ok(())
1178 }
1179
1180 #[test]
1181 fn test_move_subroutine_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1182 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo { 1 }")])?;
1183 let refactor = WorkspaceRefactor::new(index);
1184
1185 assert!(matches!(
1187 refactor.move_subroutine("", &paths[0], "Utils"),
1188 Err(RefactorError::InvalidInput(_))
1189 ));
1190
1191 assert!(matches!(
1193 refactor.move_subroutine("foo", &paths[0], ""),
1194 Err(RefactorError::InvalidInput(_))
1195 ));
1196 Ok(())
1197 }
1198
1199 #[test]
1200 fn test_inline_variable_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1201 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 42;")])?;
1202 let refactor = WorkspaceRefactor::new(index);
1203
1204 assert!(matches!(
1206 refactor.inline_variable("", &paths[0], (0, 0)),
1207 Err(RefactorError::InvalidInput(_))
1208 ));
1209 Ok(())
1210 }
1211
1212 #[test]
1214 fn test_rename_symbol_unicode_variables() -> Result<(), Box<dyn std::error::Error>> {
1215 let (_dir, index, paths) = setup_index(vec![
1216 ("unicode.pl", "my $♥ = '爱'; print $♥; # Unicode variable"),
1217 ("unicode2.pl", "use utf8; my $données = 42; print $données;"), ])?;
1219 let refactor = WorkspaceRefactor::new(index);
1220
1221 let result = refactor.rename_symbol("$♥", "$love", &paths[0], (0, 0))?;
1223 assert!(!result.file_edits.is_empty());
1224 assert!(result.description.contains("♥"));
1225
1226 let result = refactor.rename_symbol("$données", "$data", &paths[1], (0, 0))?;
1228 assert!(!result.file_edits.is_empty());
1229 assert!(result.description.contains("données"));
1230 Ok(())
1231 }
1232
1233 #[test]
1234 fn test_extract_module_unicode_content() -> Result<(), Box<dyn std::error::Error>> {
1235 let (_dir, index, paths) = setup_index(vec![(
1236 "unicode_content.pl",
1237 "# コメント in Japanese\nmy $message = \"你好世界\";\nprint $message;\n# More 中文 content\n",
1238 )])?;
1239 let refactor = WorkspaceRefactor::new(index);
1240
1241 let result = refactor.extract_module(&paths[0], 2, 3, "UnicodeUtils")?;
1242 assert_eq!(result.file_edits.len(), 2); let new_module_edit = &result.file_edits[1];
1246 assert!(new_module_edit.edits[0].new_text.contains("你好世界"));
1247 Ok(())
1248 }
1249
1250 #[test]
1251 fn test_inline_variable_unicode_expressions() -> Result<(), Box<dyn std::error::Error>> {
1252 let (_dir, index, paths) = setup_index(vec![(
1253 "unicode_expr.pl",
1254 "my $表达式 = \"测试表达式\";\nmy $result = $表达式 . \"suffix\";\nprint $result;\n",
1255 )])?;
1256 let refactor = WorkspaceRefactor::new(index);
1257
1258 let result = refactor.inline_variable("$表达式", &paths[0], (0, 0))?;
1259 assert!(!result.file_edits.is_empty());
1260
1261 let edits = &result.file_edits[0].edits;
1263 assert!(edits.iter().any(|edit| edit.new_text.contains("测试表达式")));
1264 Ok(())
1265 }
1266
1267 #[test]
1269 fn test_rename_symbol_complex_perl_constructs() -> Result<(), Box<dyn std::error::Error>> {
1270 let (_dir, index, paths) = setup_index(vec![(
1271 "complex.pl",
1272 r#"
1273package MyPackage;
1274my @array = qw($var1 $var2 $var3);
1275my %hash = ( key1 => $var1, key2 => $var2 );
1276my $ref = \$var1;
1277print "Variable in string: $var1\n";
1278$var1 =~ s/old/new/g;
1279for my $item (@{[$var1, $var2]}) {
1280 print $item;
1281}
1282"#,
1283 )])?;
1284 let refactor = WorkspaceRefactor::new(index);
1285
1286 let result = refactor.rename_symbol("$var1", "$renamed_var", &paths[0], (0, 0))?;
1287 assert!(!result.file_edits.is_empty());
1288
1289 let edits = &result.file_edits[0].edits;
1291 assert!(edits.len() >= 3);
1292 Ok(())
1293 }
1294
1295 #[test]
1296 fn test_extract_module_with_dependencies() -> Result<(), Box<dyn std::error::Error>> {
1297 let (_dir, index, paths) = setup_index(vec![(
1298 "with_deps.pl",
1299 r#"
1300use strict;
1301use warnings;
1302
1303sub utility_func {
1304 my ($param) = @_;
1305 return "utility result";
1306}
1307
1308sub main_func {
1309 my $data = "test data";
1310 my $result = utility_func($data);
1311 print $result;
1312}
1313"#,
1314 )])?;
1315 let refactor = WorkspaceRefactor::new(index);
1316
1317 let result = refactor.extract_module(&paths[0], 5, 8, "Utils")?;
1318 assert_eq!(result.file_edits.len(), 2);
1319
1320 let new_module_edit = &result.file_edits[1];
1322 assert!(new_module_edit.edits[0].new_text.contains("sub utility_func"));
1323 assert!(new_module_edit.edits[0].new_text.contains("utility result"));
1324 Ok(())
1325 }
1326
1327 #[test]
1328 fn test_optimize_imports_complex_scenarios() -> Result<(), Box<dyn std::error::Error>> {
1329 let (_dir, index, _paths) = setup_index(vec![
1330 (
1331 "complex_imports.pl",
1332 r#"
1333use strict;
1334use warnings;
1335use utf8;
1336use JSON;
1337use JSON qw(encode_json);
1338use YAML;
1339use YAML qw(Load);
1340use JSON; # Duplicate
1341"#,
1342 ),
1343 ("minimal_imports.pl", "use strict;\nuse warnings;"),
1344 ("no_imports.pl", "print 'Hello World';"),
1345 ])?;
1346 let refactor = WorkspaceRefactor::new(index);
1347
1348 let result = refactor.optimize_imports()?;
1349
1350 assert!(result.file_edits.len() <= 3);
1352
1353 for file_edit in &result.file_edits {
1355 assert!(!file_edit.edits.is_empty());
1356 }
1357 Ok(())
1358 }
1359
1360 #[test]
1361 fn test_move_subroutine_not_found() -> Result<(), Box<dyn std::error::Error>> {
1362 let (_dir, index, paths) = setup_index(vec![("empty.pl", "# No subroutines here")])?;
1363 let refactor = WorkspaceRefactor::new(index);
1364
1365 let result = refactor.move_subroutine("nonexistent", &paths[0], "Target");
1366 assert!(matches!(result, Err(RefactorError::SymbolNotFound { .. })));
1367 Ok(())
1368 }
1369
1370 #[test]
1371 fn test_inline_variable_no_initializer() -> Result<(), Box<dyn std::error::Error>> {
1372 let (_dir, index, paths) =
1373 setup_index(vec![("no_init.pl", "my $var;\n$var = 42;\nprint $var;\n")])?;
1374 let refactor = WorkspaceRefactor::new(index);
1375
1376 let result = refactor.inline_variable("$var", &paths[0], (0, 0));
1377 assert!(matches!(result, Err(RefactorError::ParseError(_))));
1379 Ok(())
1380 }
1381
1382 #[test]
1383 fn test_import_optimization_integration() -> Result<(), Box<dyn std::error::Error>> {
1384 let (_dir, index, _paths) = setup_index(vec![
1386 (
1387 "with_unused.pl",
1388 "use strict;\nuse warnings;\nuse JSON qw(encode_json unused_symbol);\n\nmy $json = encode_json('test');",
1389 ),
1390 ("clean.pl", "use strict;\nuse warnings;\n\nprint 'test';"),
1391 ])?;
1392 let refactor = WorkspaceRefactor::new(index);
1393
1394 let result = refactor.optimize_imports()?;
1395
1396 assert!(!result.file_edits.is_empty());
1399
1400 let has_optimizations = result.file_edits.iter().any(|edit| !edit.edits.is_empty());
1402 assert!(has_optimizations);
1403 Ok(())
1404 }
1405
1406 #[test]
1408 fn test_large_file_handling() -> Result<(), Box<dyn std::error::Error>> {
1409 let mut large_content = String::new();
1411 large_content.push_str("my $target = 'value';\n");
1412 for i in 0..100 {
1413 large_content.push_str(&format!("print $target; # Line {}\n", i));
1414 }
1415
1416 let (_dir, index, paths) = setup_index(vec![("large.pl", &large_content)])?;
1417 let refactor = WorkspaceRefactor::new(index);
1418
1419 let result = refactor.rename_symbol("$target", "$renamed", &paths[0], (0, 0))?;
1420 assert!(!result.file_edits.is_empty());
1421
1422 let edits = &result.file_edits[0].edits;
1424 assert_eq!(edits.len(), 101);
1425 Ok(())
1426 }
1427
1428 #[test]
1429 fn test_multiple_files_workspace() -> Result<(), Box<dyn std::error::Error>> {
1430 let files = (0..10)
1431 .map(|i| (format!("file_{}.pl", i), format!("my $shared = {}; print $shared;\n", i)))
1432 .collect::<Vec<_>>();
1433
1434 let files_refs: Vec<_> =
1435 files.iter().map(|(name, content)| (name.as_str(), content.as_str())).collect();
1436 let (_dir, index, paths) = setup_index(files_refs)?;
1437 let refactor = WorkspaceRefactor::new(index);
1438
1439 let result = refactor.rename_symbol("$shared", "$common", &paths[0], (0, 0))?;
1440 assert!(!result.file_edits.is_empty());
1441
1442 assert!(!result.description.is_empty());
1444 Ok(())
1445 }
1446
1447 #[test]
1449 fn inline_multi_file_basic() -> Result<(), Box<dyn std::error::Error>> {
1450 let (_dir, index, paths) = setup_index(vec![
1452 ("a.pl", "my $const = 42;\nprint $const;\n"),
1453 ("b.pl", "print $const;\n"),
1454 ("c.pl", "my $result = $const + 1;\n"),
1455 ])?;
1456 let refactor = WorkspaceRefactor::new(index);
1457 let result = refactor.inline_variable_all("$const", &paths[0], (0, 0))?;
1458
1459 assert!(!result.file_edits.is_empty());
1461 assert!(result.description.contains("workspace"));
1462 Ok(())
1463 }
1464
1465 #[test]
1467 fn inline_multi_file_validates_constant() -> Result<(), Box<dyn std::error::Error>> {
1468 let (_dir, index, paths) =
1470 setup_index(vec![("a.pl", "my $x = get_value();\nprint $x;\n")])?;
1471 let refactor = WorkspaceRefactor::new(index);
1472
1473 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1475 assert!(!result.file_edits.is_empty());
1476 assert!(!result.warnings.is_empty(), "Should have warning about function call");
1478 Ok(())
1479 }
1480
1481 #[test]
1483 fn inline_multi_file_respects_scope() -> Result<(), Box<dyn std::error::Error>> {
1484 let (_dir, index, paths) = setup_index(vec![
1486 ("a.pl", "package A;\nmy $pkg_var = 10;\nprint $pkg_var;\n"),
1487 ("b.pl", "package B;\nmy $pkg_var = 20;\nprint $pkg_var;\n"),
1488 ])?;
1489 let refactor = WorkspaceRefactor::new(index);
1490
1491 let result = refactor.inline_variable("$pkg_var", &paths[0], (0, 0))?;
1493 assert!(!result.file_edits.is_empty());
1494 Ok(())
1495 }
1496
1497 #[test]
1499 fn inline_multi_file_supports_all_types() -> Result<(), Box<dyn std::error::Error>> {
1500 let (_dir, index, paths) = setup_index(vec![("scalar.pl", "my $x = 42;\nprint $x;\n")])?;
1502 let refactor = WorkspaceRefactor::new(index);
1503
1504 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1506 assert!(!result.file_edits.is_empty());
1507
1508 Ok(())
1509 }
1510
1511 #[test]
1513 fn inline_multi_file_reports_occurrences() -> Result<(), Box<dyn std::error::Error>> {
1514 let (_dir, index, paths) = setup_index(vec![
1516 ("a.pl", "my $x = 42;\nprint $x;\nprint $x;\nprint $x;\n"),
1517 ("b.pl", "print $x;\nprint $x;\n"),
1518 ])?;
1519 let refactor = WorkspaceRefactor::new(index);
1520 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1521
1522 assert!(
1524 result.description.contains("occurrence") || result.description.contains("workspace")
1525 );
1526 Ok(())
1527 }
1528
1529 #[test]
1530 fn inline_multi_file_uses_definition_package_for_index_lookup()
1531 -> Result<(), Box<dyn std::error::Error>> {
1532 let (_dir, index, paths) = setup_index(vec![
1533 ("a.pl", "package Foo;\nmy $x = 42;\nprint $x;\n"),
1534 ("b.pl", "package Foo;\nprint $x;\n"),
1535 ("c.pl", "package Bar;\nprint $x;\n"),
1536 ])?;
1537 let refactor = WorkspaceRefactor::new(index);
1538 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1539
1540 let edited_files = result.file_edits.len();
1541 assert_eq!(edited_files, 2, "Should edit only Foo package files");
1542 Ok(())
1543 }
1544
1545 #[test]
1546 fn inline_variable_all_uses_definition_site_package_not_file_first_package()
1547 -> Result<(), Box<dyn std::error::Error>> {
1548 let (_dir, index, paths) = setup_index(vec![
1552 (
1553 "multi.pl",
1554 "package Bar;
1555sub bar {}
1556package Foo;
1557my $x = 42;
1558print $x;
1559",
1560 ),
1561 (
1562 "foo_user.pl",
1563 "package Foo;
1564print $x;
1565",
1566 ),
1567 (
1568 "bar_user.pl",
1569 "package Bar;
1570print $x;
1571",
1572 ),
1573 ])?;
1574 let refactor = WorkspaceRefactor::new(index);
1575 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1577
1578 let edited_uris: Vec<_> = result.file_edits.iter().map(|e| &e.file_path).collect();
1579 assert!(
1581 edited_uris.iter().any(|p| p.to_string_lossy().contains("foo_user")),
1582 "foo_user.pl should be edited (same package Foo)"
1583 );
1584 assert!(
1585 !edited_uris.iter().any(|p| p.to_string_lossy().contains("bar_user")),
1586 "bar_user.pl must NOT be edited (different package Bar)"
1587 );
1588 Ok(())
1589 }
1590}