1use crate::import_optimizer::ImportOptimizer;
54use crate::workspace_index::{
55 SymKind, SymbolKey, WorkspaceIndex, fs_path_to_uri, normalize_var, uri_to_fs_path,
56};
57use perl_module_path::module_name_to_path;
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(crate::workspace_index::Location {
309 uri: doc.uri.clone(),
310 range: crate::position::Range {
311 start: crate::position::Position {
312 byte: start_byte,
313 line: start_line,
314 column: start_col,
315 },
316 end: crate::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 (sigil, bare) = normalize_var(var_name);
803 let key = SymbolKey {
804 pkg: Arc::from("main".to_string()),
805 name: Arc::from(bare.to_string()),
806 sigil,
807 kind: SymKind::Var,
808 };
809
810 let def_uri = fs_path_to_uri(def_file_path).map_err(|e| {
811 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
812 })?;
813 let store = self._index.document_store();
814 let def_doc = store.get(&def_uri).ok_or_else(|| {
815 RefactorError::DocumentNotIndexed(def_file_path.display().to_string())
816 })?;
817
818 let def_line_idx = def_doc
819 .text
820 .lines()
821 .position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
822 .ok_or_else(|| RefactorError::SymbolNotFound {
823 symbol: var_name.to_string(),
824 file: def_file_path.display().to_string(),
825 })?;
826
827 let def_line = def_doc.text.lines().nth(def_line_idx).unwrap_or("");
828
829 let expr = def_line
830 .split('=')
831 .nth(1)
832 .map(|s| s.trim().trim_end_matches(';'))
833 .ok_or_else(|| {
834 RefactorError::ParseError(format!(
835 "Variable '{}' has no initializer in line: {}",
836 var_name, def_line
837 ))
838 })?
839 .to_string();
840
841 let mut warnings = Vec::new();
842
843 if expr.contains('(') && expr.contains(')') {
844 warnings.push(format!(
845 "Warning: Initializer '{}' may contain function calls or side effects",
846 expr
847 ));
848 }
849
850 let mut all_locations = self._index.find_refs(&key);
851
852 if let Some(def_loc) = self._index.find_def(&key) {
853 if !all_locations.iter().any(|loc| loc.uri == def_loc.uri && loc.range == def_loc.range)
854 {
855 all_locations.push(def_loc);
856 }
857 }
858
859 if all_locations.is_empty() {
860 for doc in store.all_documents() {
861 if !doc.text.contains(var_name) {
862 continue;
863 }
864
865 let idx = doc.line_index.clone();
866 let mut pos = 0;
867
868 while let Some(found) = doc.text[pos..].find(var_name) {
869 let start = pos + found;
870 let end = start + var_name.len();
871
872 if start >= doc.text.len() || end > doc.text.len() {
873 break;
874 }
875
876 let (start_line, start_col) = idx.offset_to_position(start);
877 let (end_line, end_col) = idx.offset_to_position(end);
878 let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
879 let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
880
881 all_locations.push(crate::workspace_index::Location {
882 uri: doc.uri.clone(),
883 range: crate::position::Range {
884 start: crate::position::Position {
885 byte: start_byte,
886 line: start_line,
887 column: start_col,
888 },
889 end: crate::position::Position {
890 byte: end_byte,
891 line: end_line,
892 column: end_col,
893 },
894 },
895 });
896 pos = end;
897
898 if all_locations.len() >= 1000 {
899 warnings.push(
900 "Warning: More than 1000 occurrences found, limiting results"
901 .to_string(),
902 );
903 break;
904 }
905 }
906
907 if all_locations.len() >= 1000 {
908 break;
909 }
910 }
911 }
912
913 let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
914 let mut total_occurrences = 0;
915 let mut files_affected = std::collections::HashSet::new();
916
917 for loc in all_locations {
918 let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
919 RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
920 })?;
921
922 files_affected.insert(path.clone());
923
924 if let Some(doc) = store.get(&loc.uri) {
925 let start_off =
926 doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
927 let end_off =
928 doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
929
930 if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
931 let is_definition = doc.uri == def_uri
932 && doc.text[start_off.saturating_sub(10)..start_off.min(doc.text.len())]
933 .contains("my ");
934
935 if is_definition {
936 let line_start =
937 doc.text[..start_off].rfind('\n').map(|p| p + 1).unwrap_or(0);
938 let line_end = doc.text[end_off..]
939 .find('\n')
940 .map(|p| end_off + p + 1)
941 .unwrap_or(doc.text.len());
942
943 edits_by_file.entry(path).or_default().push(TextEdit {
944 start: line_start,
945 end: line_end,
946 new_text: String::new(),
947 });
948 } else {
949 edits_by_file.entry(path).or_default().push(TextEdit {
950 start: start_off,
951 end: end_off,
952 new_text: expr.clone(),
953 });
954 total_occurrences += 1;
955 }
956 }
957 }
958 }
959
960 let file_edits: Vec<FileEdit> = edits_by_file
961 .into_iter()
962 .map(|(file_path, edits)| FileEdit { file_path, edits })
963 .collect();
964
965 let description = format!(
966 "Inline variable '{}' across workspace: {} occurrences in {} files",
967 var_name,
968 total_occurrences,
969 files_affected.len()
970 );
971
972 Ok(RefactorResult { file_edits, description, warnings })
973 }
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979 use tempfile::{TempDir, tempdir};
980
981 fn setup_index(
982 files: Vec<(&str, &str)>,
983 ) -> Result<(TempDir, WorkspaceIndex, Vec<PathBuf>), Box<dyn std::error::Error>> {
984 let dir = tempdir()?;
985 let mut paths = Vec::new();
986 let index = WorkspaceIndex::new();
987 for (name, content) in files {
988 let path = dir.path().join(name);
989 std::fs::write(&path, content)?;
990 let path_str = path.to_str().ok_or_else(|| {
991 format!("Failed to convert path to string for test file: {}", name)
992 })?;
993 index.index_file_str(path_str, content)?;
994 paths.push(path);
995 }
996 Ok((dir, index, paths))
997 }
998
999 #[test]
1000 fn test_rename_symbol() -> Result<(), Box<dyn std::error::Error>> {
1001 let (_dir, index, paths) =
1002 setup_index(vec![("a.pl", "my $foo = 1; print $foo;"), ("b.pl", "print $foo;")])?;
1003 let refactor = WorkspaceRefactor::new(index);
1004 let result = refactor.rename_symbol("$foo", "$bar", &paths[0], (0, 0))?;
1005 assert!(!result.file_edits.is_empty());
1006 Ok(())
1007 }
1008
1009 #[test]
1010 fn test_extract_module() -> Result<(), Box<dyn std::error::Error>> {
1011 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1012 let refactor = WorkspaceRefactor::new(index);
1013 let res = refactor.extract_module(&paths[0], 2, 2, "Extracted")?;
1014 assert_eq!(res.file_edits.len(), 2);
1015 Ok(())
1016 }
1017
1018 #[test]
1019 fn test_extract_module_qualified_name_uses_nested_path()
1020 -> Result<(), Box<dyn std::error::Error>> {
1021 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1022 let refactor = WorkspaceRefactor::new(index);
1023 let res = refactor.extract_module(&paths[0], 2, 2, "My::Extracted")?;
1024 assert_eq!(res.file_edits.len(), 2);
1025 assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("My/Extracted.pm"));
1026 Ok(())
1027 }
1028
1029 #[test]
1030 fn test_optimize_imports() -> Result<(), Box<dyn std::error::Error>> {
1031 let (_dir, index, _paths) = setup_index(vec![
1032 ("a.pl", "use B;\nuse A;\nuse B;\n"),
1033 ("b.pl", "use C;\nuse A;\nuse C;\n"),
1034 ])?;
1035 let refactor = WorkspaceRefactor::new(index);
1036 let res = refactor.optimize_imports()?;
1037 assert_eq!(res.file_edits.len(), 2);
1038 Ok(())
1039 }
1040
1041 #[test]
1042 fn test_move_subroutine() -> Result<(), Box<dyn std::error::Error>> {
1043 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1044 let refactor = WorkspaceRefactor::new(index);
1045 let res = refactor.move_subroutine("foo", &paths[0], "b")?;
1046 assert_eq!(res.file_edits.len(), 2);
1047 Ok(())
1048 }
1049
1050 #[test]
1051 fn test_move_subroutine_qualified_target_uses_nested_path()
1052 -> Result<(), Box<dyn std::error::Error>> {
1053 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1054 let refactor = WorkspaceRefactor::new(index);
1055 let res = refactor.move_subroutine("foo", &paths[0], "Target::Module")?;
1056 assert_eq!(res.file_edits.len(), 2);
1057 assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("Target/Module.pm"));
1058 Ok(())
1059 }
1060
1061 #[test]
1062 fn test_inline_variable() -> Result<(), Box<dyn std::error::Error>> {
1063 let (_dir, index, paths) =
1064 setup_index(vec![("a.pl", "my $x = 42;\nmy $y = $x + 1;\nprint $y;\n")])?;
1065 let refactor = WorkspaceRefactor::new(index);
1066 let result = refactor.inline_variable("$x", &paths[0], (0, 0))?;
1067 assert!(!result.file_edits.is_empty());
1068 Ok(())
1069 }
1070
1071 #[test]
1073 fn test_rename_symbol_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1074 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $foo = 1;")])?;
1075 let refactor = WorkspaceRefactor::new(index);
1076
1077 assert!(matches!(
1079 refactor.rename_symbol("", "$bar", &paths[0], (0, 0)),
1080 Err(RefactorError::InvalidInput(_))
1081 ));
1082
1083 assert!(matches!(
1085 refactor.rename_symbol("$foo", "", &paths[0], (0, 0)),
1086 Err(RefactorError::InvalidInput(_))
1087 ));
1088
1089 assert!(matches!(
1091 refactor.rename_symbol("$foo", "$foo", &paths[0], (0, 0)),
1092 Err(RefactorError::InvalidInput(_))
1093 ));
1094 Ok(())
1095 }
1096
1097 #[test]
1098 fn test_extract_module_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1099 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1100 let refactor = WorkspaceRefactor::new(index);
1101
1102 assert!(matches!(
1104 refactor.extract_module(&paths[0], 1, 2, ""),
1105 Err(RefactorError::InvalidInput(_))
1106 ));
1107
1108 assert!(matches!(
1110 refactor.extract_module(&paths[0], 5, 2, "Test"),
1111 Err(RefactorError::InvalidInput(_))
1112 ));
1113 Ok(())
1114 }
1115
1116 #[test]
1117 fn test_move_subroutine_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1118 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo { 1 }")])?;
1119 let refactor = WorkspaceRefactor::new(index);
1120
1121 assert!(matches!(
1123 refactor.move_subroutine("", &paths[0], "Utils"),
1124 Err(RefactorError::InvalidInput(_))
1125 ));
1126
1127 assert!(matches!(
1129 refactor.move_subroutine("foo", &paths[0], ""),
1130 Err(RefactorError::InvalidInput(_))
1131 ));
1132 Ok(())
1133 }
1134
1135 #[test]
1136 fn test_inline_variable_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1137 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 42;")])?;
1138 let refactor = WorkspaceRefactor::new(index);
1139
1140 assert!(matches!(
1142 refactor.inline_variable("", &paths[0], (0, 0)),
1143 Err(RefactorError::InvalidInput(_))
1144 ));
1145 Ok(())
1146 }
1147
1148 #[test]
1150 fn test_rename_symbol_unicode_variables() -> Result<(), Box<dyn std::error::Error>> {
1151 let (_dir, index, paths) = setup_index(vec![
1152 ("unicode.pl", "my $♥ = '爱'; print $♥; # Unicode variable"),
1153 ("unicode2.pl", "use utf8; my $données = 42; print $données;"), ])?;
1155 let refactor = WorkspaceRefactor::new(index);
1156
1157 let result = refactor.rename_symbol("$♥", "$love", &paths[0], (0, 0))?;
1159 assert!(!result.file_edits.is_empty());
1160 assert!(result.description.contains("♥"));
1161
1162 let result = refactor.rename_symbol("$données", "$data", &paths[1], (0, 0))?;
1164 assert!(!result.file_edits.is_empty());
1165 assert!(result.description.contains("données"));
1166 Ok(())
1167 }
1168
1169 #[test]
1170 fn test_extract_module_unicode_content() -> Result<(), Box<dyn std::error::Error>> {
1171 let (_dir, index, paths) = setup_index(vec![(
1172 "unicode_content.pl",
1173 "# コメント in Japanese\nmy $message = \"你好世界\";\nprint $message;\n# More 中文 content\n",
1174 )])?;
1175 let refactor = WorkspaceRefactor::new(index);
1176
1177 let result = refactor.extract_module(&paths[0], 2, 3, "UnicodeUtils")?;
1178 assert_eq!(result.file_edits.len(), 2); let new_module_edit = &result.file_edits[1];
1182 assert!(new_module_edit.edits[0].new_text.contains("你好世界"));
1183 Ok(())
1184 }
1185
1186 #[test]
1187 fn test_inline_variable_unicode_expressions() -> Result<(), Box<dyn std::error::Error>> {
1188 let (_dir, index, paths) = setup_index(vec![(
1189 "unicode_expr.pl",
1190 "my $表达式 = \"测试表达式\";\nmy $result = $表达式 . \"suffix\";\nprint $result;\n",
1191 )])?;
1192 let refactor = WorkspaceRefactor::new(index);
1193
1194 let result = refactor.inline_variable("$表达式", &paths[0], (0, 0))?;
1195 assert!(!result.file_edits.is_empty());
1196
1197 let edits = &result.file_edits[0].edits;
1199 assert!(edits.iter().any(|edit| edit.new_text.contains("测试表达式")));
1200 Ok(())
1201 }
1202
1203 #[test]
1205 fn test_rename_symbol_complex_perl_constructs() -> Result<(), Box<dyn std::error::Error>> {
1206 let (_dir, index, paths) = setup_index(vec![(
1207 "complex.pl",
1208 r#"
1209package MyPackage;
1210my @array = qw($var1 $var2 $var3);
1211my %hash = ( key1 => $var1, key2 => $var2 );
1212my $ref = \$var1;
1213print "Variable in string: $var1\n";
1214$var1 =~ s/old/new/g;
1215for my $item (@{[$var1, $var2]}) {
1216 print $item;
1217}
1218"#,
1219 )])?;
1220 let refactor = WorkspaceRefactor::new(index);
1221
1222 let result = refactor.rename_symbol("$var1", "$renamed_var", &paths[0], (0, 0))?;
1223 assert!(!result.file_edits.is_empty());
1224
1225 let edits = &result.file_edits[0].edits;
1227 assert!(edits.len() >= 3);
1228 Ok(())
1229 }
1230
1231 #[test]
1232 fn test_extract_module_with_dependencies() -> Result<(), Box<dyn std::error::Error>> {
1233 let (_dir, index, paths) = setup_index(vec![(
1234 "with_deps.pl",
1235 r#"
1236use strict;
1237use warnings;
1238
1239sub utility_func {
1240 my ($param) = @_;
1241 return "utility result";
1242}
1243
1244sub main_func {
1245 my $data = "test data";
1246 my $result = utility_func($data);
1247 print $result;
1248}
1249"#,
1250 )])?;
1251 let refactor = WorkspaceRefactor::new(index);
1252
1253 let result = refactor.extract_module(&paths[0], 5, 8, "Utils")?;
1254 assert_eq!(result.file_edits.len(), 2);
1255
1256 let new_module_edit = &result.file_edits[1];
1258 assert!(new_module_edit.edits[0].new_text.contains("sub utility_func"));
1259 assert!(new_module_edit.edits[0].new_text.contains("utility result"));
1260 Ok(())
1261 }
1262
1263 #[test]
1264 fn test_optimize_imports_complex_scenarios() -> Result<(), Box<dyn std::error::Error>> {
1265 let (_dir, index, _paths) = setup_index(vec![
1266 (
1267 "complex_imports.pl",
1268 r#"
1269use strict;
1270use warnings;
1271use utf8;
1272use JSON;
1273use JSON qw(encode_json);
1274use YAML;
1275use YAML qw(Load);
1276use JSON; # Duplicate
1277"#,
1278 ),
1279 ("minimal_imports.pl", "use strict;\nuse warnings;"),
1280 ("no_imports.pl", "print 'Hello World';"),
1281 ])?;
1282 let refactor = WorkspaceRefactor::new(index);
1283
1284 let result = refactor.optimize_imports()?;
1285
1286 assert!(result.file_edits.len() <= 3);
1288
1289 for file_edit in &result.file_edits {
1291 assert!(!file_edit.edits.is_empty());
1292 }
1293 Ok(())
1294 }
1295
1296 #[test]
1297 fn test_move_subroutine_not_found() -> Result<(), Box<dyn std::error::Error>> {
1298 let (_dir, index, paths) = setup_index(vec![("empty.pl", "# No subroutines here")])?;
1299 let refactor = WorkspaceRefactor::new(index);
1300
1301 let result = refactor.move_subroutine("nonexistent", &paths[0], "Target");
1302 assert!(matches!(result, Err(RefactorError::SymbolNotFound { .. })));
1303 Ok(())
1304 }
1305
1306 #[test]
1307 fn test_inline_variable_no_initializer() -> Result<(), Box<dyn std::error::Error>> {
1308 let (_dir, index, paths) =
1309 setup_index(vec![("no_init.pl", "my $var;\n$var = 42;\nprint $var;\n")])?;
1310 let refactor = WorkspaceRefactor::new(index);
1311
1312 let result = refactor.inline_variable("$var", &paths[0], (0, 0));
1313 assert!(matches!(result, Err(RefactorError::ParseError(_))));
1315 Ok(())
1316 }
1317
1318 #[test]
1319 fn test_import_optimization_integration() -> Result<(), Box<dyn std::error::Error>> {
1320 let (_dir, index, _paths) = setup_index(vec![
1322 (
1323 "with_unused.pl",
1324 "use strict;\nuse warnings;\nuse JSON qw(encode_json unused_symbol);\n\nmy $json = encode_json('test');",
1325 ),
1326 ("clean.pl", "use strict;\nuse warnings;\n\nprint 'test';"),
1327 ])?;
1328 let refactor = WorkspaceRefactor::new(index);
1329
1330 let result = refactor.optimize_imports()?;
1331
1332 assert!(!result.file_edits.is_empty());
1335
1336 let has_optimizations = result.file_edits.iter().any(|edit| !edit.edits.is_empty());
1338 assert!(has_optimizations);
1339 Ok(())
1340 }
1341
1342 #[test]
1344 fn test_large_file_handling() -> Result<(), Box<dyn std::error::Error>> {
1345 let mut large_content = String::new();
1347 large_content.push_str("my $target = 'value';\n");
1348 for i in 0..100 {
1349 large_content.push_str(&format!("print $target; # Line {}\n", i));
1350 }
1351
1352 let (_dir, index, paths) = setup_index(vec![("large.pl", &large_content)])?;
1353 let refactor = WorkspaceRefactor::new(index);
1354
1355 let result = refactor.rename_symbol("$target", "$renamed", &paths[0], (0, 0))?;
1356 assert!(!result.file_edits.is_empty());
1357
1358 let edits = &result.file_edits[0].edits;
1360 assert_eq!(edits.len(), 101);
1361 Ok(())
1362 }
1363
1364 #[test]
1365 fn test_multiple_files_workspace() -> Result<(), Box<dyn std::error::Error>> {
1366 let files = (0..10)
1367 .map(|i| (format!("file_{}.pl", i), format!("my $shared = {}; print $shared;\n", i)))
1368 .collect::<Vec<_>>();
1369
1370 let files_refs: Vec<_> =
1371 files.iter().map(|(name, content)| (name.as_str(), content.as_str())).collect();
1372 let (_dir, index, paths) = setup_index(files_refs)?;
1373 let refactor = WorkspaceRefactor::new(index);
1374
1375 let result = refactor.rename_symbol("$shared", "$common", &paths[0], (0, 0))?;
1376 assert!(!result.file_edits.is_empty());
1377
1378 assert!(!result.description.is_empty());
1380 Ok(())
1381 }
1382
1383 #[test]
1385 fn inline_multi_file_basic() -> Result<(), Box<dyn std::error::Error>> {
1386 let (_dir, index, paths) = setup_index(vec![
1388 ("a.pl", "my $const = 42;\nprint $const;\n"),
1389 ("b.pl", "print $const;\n"),
1390 ("c.pl", "my $result = $const + 1;\n"),
1391 ])?;
1392 let refactor = WorkspaceRefactor::new(index);
1393 let result = refactor.inline_variable_all("$const", &paths[0], (0, 0))?;
1394
1395 assert!(!result.file_edits.is_empty());
1397 assert!(result.description.contains("workspace"));
1398 Ok(())
1399 }
1400
1401 #[test]
1403 fn inline_multi_file_validates_constant() -> Result<(), Box<dyn std::error::Error>> {
1404 let (_dir, index, paths) =
1406 setup_index(vec![("a.pl", "my $x = get_value();\nprint $x;\n")])?;
1407 let refactor = WorkspaceRefactor::new(index);
1408
1409 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1411 assert!(!result.file_edits.is_empty());
1412 assert!(!result.warnings.is_empty(), "Should have warning about function call");
1414 Ok(())
1415 }
1416
1417 #[test]
1419 fn inline_multi_file_respects_scope() -> Result<(), Box<dyn std::error::Error>> {
1420 let (_dir, index, paths) = setup_index(vec![
1422 ("a.pl", "package A;\nmy $pkg_var = 10;\nprint $pkg_var;\n"),
1423 ("b.pl", "package B;\nmy $pkg_var = 20;\nprint $pkg_var;\n"),
1424 ])?;
1425 let refactor = WorkspaceRefactor::new(index);
1426
1427 let result = refactor.inline_variable("$pkg_var", &paths[0], (0, 0))?;
1429 assert!(!result.file_edits.is_empty());
1430 Ok(())
1431 }
1432
1433 #[test]
1435 fn inline_multi_file_supports_all_types() -> Result<(), Box<dyn std::error::Error>> {
1436 let (_dir, index, paths) = setup_index(vec![("scalar.pl", "my $x = 42;\nprint $x;\n")])?;
1438 let refactor = WorkspaceRefactor::new(index);
1439
1440 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1442 assert!(!result.file_edits.is_empty());
1443
1444 Ok(())
1445 }
1446
1447 #[test]
1449 fn inline_multi_file_reports_occurrences() -> Result<(), Box<dyn std::error::Error>> {
1450 let (_dir, index, paths) = setup_index(vec![
1452 ("a.pl", "my $x = 42;\nprint $x;\nprint $x;\nprint $x;\n"),
1453 ("b.pl", "print $x;\nprint $x;\n"),
1454 ])?;
1455 let refactor = WorkspaceRefactor::new(index);
1456 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1457
1458 assert!(
1460 result.description.contains("occurrence") || result.description.contains("workspace")
1461 );
1462 Ok(())
1463 }
1464}