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 println!("rename_symbol DEBUG: search key={:?}", key);
266 println!(
267 "rename_symbol DEBUG: all symbols in index: {:?}",
268 self._index.all_symbols().iter().map(|s| &s.name).collect::<Vec<_>>()
269 );
270
271 let mut edits: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
272
273 let mut locations = self._index.find_refs(&key);
275 println!("rename_symbol DEBUG: find_refs result count: {}", locations.len());
276
277 let def_loc = self._index.find_def(&key);
279 println!("rename_symbol DEBUG: find_def result: {:?}", def_loc);
280 if let Some(def) = def_loc {
281 if !locations.iter().any(|loc| loc.uri == def.uri && loc.range == def.range) {
282 locations.push(def);
283 }
284 }
285
286 let store = self._index.document_store();
287
288 println!("rename_symbol DEBUG: store has {} documents", store.all_documents().len());
289 for doc in store.all_documents() {
290 println!("rename_symbol DEBUG: doc in store: {}", doc.uri);
291 }
292
293 if locations.is_empty() {
294 println!(
296 "rename_symbol DEBUG: locations empty, using fallback naive search for {}",
297 old_name
298 );
299 let _old_name_bytes = old_name.as_bytes();
301
302 for doc in store.all_documents() {
303 println!(
305 "rename_symbol DEBUG: naive search checking doc: {}, contains {}: {}",
306 doc.uri,
307 old_name,
308 doc.text.contains(old_name)
309 );
310 if !doc.text.contains(old_name) {
311 continue;
312 }
313
314 let idx = doc.line_index.clone();
315 let mut pos = 0;
316 let _text_bytes = doc.text.as_bytes();
317
318 while let Some(found) = doc.text[pos..].find(old_name) {
320 let start = pos + found;
321 let end = start + old_name.len();
322
323 if start >= doc.text.len() || end > doc.text.len() {
325 break;
326 }
327
328 let (start_line, start_col) = idx.offset_to_position(start);
329 let (end_line, end_col) = idx.offset_to_position(end);
330 let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
331 let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
332 locations.push(crate::workspace_index::Location {
333 uri: doc.uri.clone(),
334 range: crate::position::Range {
335 start: crate::position::Position {
336 byte: start_byte,
337 line: start_line,
338 column: start_col,
339 },
340 end: crate::position::Position {
341 byte: end_byte,
342 line: end_line,
343 column: end_col,
344 },
345 },
346 });
347 pos = end;
348
349 if locations.len() >= 1000 {
351 break;
352 }
353 }
354
355 if locations.len() >= 500 {
358 break;
359 }
360 }
361 }
362
363 for loc in locations {
364 println!(
365 "rename_symbol DEBUG: processing location: {} at {}:{}",
366 loc.uri, loc.range.start.line, loc.range.start.column
367 );
368 let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
369 RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
370 })?;
371 if let Some(doc) = store.get(&loc.uri) {
372 let start_off =
373 doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
374 let end_off =
375 doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
376 println!(
377 "rename_symbol DEBUG: offset for {}:{}: start={:?}, end={:?}",
378 loc.range.start.line, loc.range.start.column, start_off, end_off
379 );
380 if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
381 let replacement = match kind {
382 SymKind::Var => {
383 let sig = sigil.unwrap_or('$');
384 format!("{}{}", sig, new_name.trim_start_matches(['$', '@', '%']))
385 }
386 _ => new_name.to_string(),
387 };
388 println!(
389 "rename_symbol DEBUG: replacement for {} is {}",
390 old_name, replacement
391 );
392 edits.entry(path).or_default().push(TextEdit {
393 start: start_off,
394 end: end_off,
395 new_text: replacement,
396 });
397 }
398 }
399 }
400
401 let file_edits: Vec<FileEdit> =
402 edits.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
403
404 let description = format!("Rename '{}' to '{}'", old_name, new_name);
405 println!(
406 "rename_symbol DEBUG: returning RefactorResult with {} file_edits, description: {}",
407 file_edits.len(),
408 description
409 );
410 Ok(RefactorResult { file_edits, description, warnings: vec![] })
411 }
412
413 pub fn extract_module(
452 &self,
453 file_path: &Path,
454 start_line: usize,
455 end_line: usize,
456 module_name: &str,
457 ) -> Result<RefactorResult, RefactorError> {
458 if module_name.is_empty() {
460 return Err(RefactorError::InvalidInput("Module name cannot be empty".to_string()));
461 }
462 if start_line > end_line {
463 return Err(RefactorError::InvalidInput(
464 "Start line cannot be after end line".to_string(),
465 ));
466 }
467
468 let uri = fs_path_to_uri(file_path).map_err(|e| {
469 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
470 })?;
471 let store = self._index.document_store();
472 let doc = store
473 .get(&uri)
474 .ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
475 let idx = doc.line_index.clone();
476
477 let start_off = idx.position_to_offset(start_line as u32 - 1, 0).ok_or_else(|| {
479 RefactorError::InvalidPosition {
480 file: file_path.display().to_string(),
481 details: format!("Invalid start line: {}", start_line),
482 }
483 })?;
484 let end_off = idx.position_to_offset(end_line as u32, 0).unwrap_or(doc.text.len());
485
486 let extracted = doc.text[start_off..end_off].to_string();
487
488 let original_edits = vec![TextEdit {
490 start: start_off,
491 end: end_off,
492 new_text: format!("use {};\n", module_name),
493 }];
494
495 let new_path = file_path.with_file_name(module_name_to_path(module_name));
497 let new_edits = vec![TextEdit { start: 0, end: 0, new_text: extracted }];
498
499 let file_edits = vec![
500 FileEdit { file_path: file_path.to_path_buf(), edits: original_edits },
501 FileEdit { file_path: new_path.clone(), edits: new_edits },
502 ];
503
504 Ok(RefactorResult {
505 file_edits,
506 description: format!(
507 "Extract {} lines from {} into module '{}'",
508 end_line - start_line + 1,
509 file_path.display(),
510 module_name
511 ),
512 warnings: vec![],
513 })
514 }
515
516 pub fn optimize_imports(&self) -> Result<RefactorResult, String> {
529 let optimizer = ImportOptimizer::new();
530 let mut file_edits = Vec::new();
531
532 for doc in self._index.document_store().all_documents() {
534 let Some(path) = uri_to_fs_path(&doc.uri) else { continue };
535
536 let analysis = optimizer.analyze_content(&doc.text)?;
537 let optimized = optimizer.generate_optimized_imports(&analysis);
538
539 if optimized.is_empty() {
540 continue;
541 }
542
543 let (start, end) = if let Some(import_block_re) = get_import_block_regex() {
545 if let Some(m) = import_block_re.find(&doc.text) {
546 (m.start(), m.end())
547 } else {
548 (0, 0)
549 }
550 } else {
551 (0, 0)
553 };
554
555 file_edits.push(FileEdit {
556 file_path: path.clone(),
557 edits: vec![TextEdit { start, end, new_text: format!("{}\n", optimized) }],
558 });
559 }
560
561 Ok(RefactorResult {
562 file_edits,
563 description: "Optimize imports across workspace".to_string(),
564 warnings: vec![],
565 })
566 }
567
568 pub fn move_subroutine(
607 &self,
608 sub_name: &str,
609 from_file: &Path,
610 to_module: &str,
611 ) -> Result<RefactorResult, RefactorError> {
612 if sub_name.is_empty() {
614 return Err(RefactorError::InvalidInput("Subroutine name cannot be empty".to_string()));
615 }
616 if to_module.is_empty() {
617 return Err(RefactorError::InvalidInput(
618 "Target module name cannot be empty".to_string(),
619 ));
620 }
621
622 let uri = fs_path_to_uri(from_file).map_err(|e| {
623 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
624 })?;
625 let symbols = self._index.file_symbols(&uri);
626 let sym = symbols.into_iter().find(|s| s.name == sub_name).ok_or_else(|| {
627 RefactorError::SymbolNotFound {
628 symbol: sub_name.to_string(),
629 file: from_file.display().to_string(),
630 }
631 })?;
632
633 let store = self._index.document_store();
634 let doc = store
635 .get(&uri)
636 .ok_or_else(|| RefactorError::DocumentNotIndexed(from_file.display().to_string()))?;
637 let idx = doc.line_index.clone();
638 let start_off = idx
639 .position_to_offset(sym.range.start.line, sym.range.start.column)
640 .ok_or_else(|| RefactorError::InvalidPosition {
641 file: from_file.display().to_string(),
642 details: format!(
643 "Invalid start position for subroutine '{}' at line {}, column {}",
644 sub_name, sym.range.start.line, sym.range.start.column
645 ),
646 })?;
647 let end_off =
648 idx.position_to_offset(sym.range.end.line, sym.range.end.column).ok_or_else(|| {
649 RefactorError::InvalidPosition {
650 file: from_file.display().to_string(),
651 details: format!(
652 "Invalid end position for subroutine '{}' at line {}, column {}",
653 sub_name, sym.range.end.line, sym.range.end.column
654 ),
655 }
656 })?;
657 let sub_text = doc.text[start_off..end_off].to_string();
658
659 let mut file_edits = vec![FileEdit {
661 file_path: from_file.to_path_buf(),
662 edits: vec![TextEdit { start: start_off, end: end_off, new_text: String::new() }],
663 }];
664
665 let target_path = from_file.with_file_name(module_name_to_path(to_module));
667 let target_uri = fs_path_to_uri(&target_path).map_err(|e| {
668 RefactorError::UriConversion(format!("Failed to convert target path to URI: {}", e))
669 })?;
670 let target_doc = store.get(&target_uri);
671 let insertion_offset = target_doc.as_ref().map(|d| d.text.len()).unwrap_or(0);
672
673 file_edits.push(FileEdit {
674 file_path: target_path.clone(),
675 edits: vec![TextEdit {
676 start: insertion_offset,
677 end: insertion_offset,
678 new_text: sub_text,
679 }],
680 });
681
682 Ok(RefactorResult {
683 file_edits,
684 description: format!(
685 "Move subroutine '{}' from {} to module '{}'",
686 sub_name,
687 from_file.display(),
688 to_module
689 ),
690 warnings: vec![],
691 })
692 }
693
694 pub fn inline_variable(
732 &self,
733 var_name: &str,
734 file_path: &Path,
735 _position: (usize, usize),
736 ) -> Result<RefactorResult, RefactorError> {
737 let (sigil, bare) = normalize_var(var_name);
738 let _key = SymbolKey {
739 pkg: Arc::from("main".to_string()),
740 name: Arc::from(bare.to_string()),
741 sigil,
742 kind: SymKind::Var,
743 };
744
745 if var_name.is_empty() {
747 return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
748 }
749
750 let uri = fs_path_to_uri(file_path).map_err(|e| {
751 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
752 })?;
753 let store = self._index.document_store();
754 let doc = store
755 .get(&uri)
756 .ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
757 let idx = doc.line_index.clone();
758
759 let def_line_idx = doc
761 .text
762 .lines()
763 .position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
764 .ok_or_else(|| RefactorError::SymbolNotFound {
765 symbol: var_name.to_string(),
766 file: file_path.display().to_string(),
767 })?;
768 let def_line_start = idx.position_to_offset(def_line_idx as u32, 0).ok_or_else(|| {
769 RefactorError::InvalidPosition {
770 file: file_path.display().to_string(),
771 details: format!("Invalid start position for definition line: {}", def_line_idx),
772 }
773 })?;
774 let def_line_end =
775 idx.position_to_offset(def_line_idx as u32 + 1, 0).unwrap_or(doc.text.len());
776 let def_line = doc.text.lines().nth(def_line_idx).unwrap_or("");
777 let expr = def_line
778 .split('=')
779 .nth(1)
780 .map(|s| s.trim().trim_end_matches(';'))
781 .ok_or_else(|| {
782 RefactorError::ParseError(format!(
783 "Variable '{}' has no initializer in line: {}",
784 var_name, def_line
785 ))
786 })?
787 .to_string();
788
789 let mut edits_map: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
790
791 edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
793 start: def_line_start,
794 end: def_line_end,
795 new_text: String::new(),
796 });
797
798 let mut search_pos = def_line_end;
800 while let Some(found) = doc.text[search_pos..].find(var_name) {
801 let start = search_pos + found;
802 let end = start + var_name.len();
803 edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
804 start,
805 end,
806 new_text: expr.clone(),
807 });
808 search_pos = end;
809 }
810
811 let file_edits =
812 edits_map.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
813
814 Ok(RefactorResult {
815 file_edits,
816 description: format!("Inline variable '{}' in {}", var_name, file_path.display()),
817 warnings: vec![],
818 })
819 }
820
821 pub fn inline_variable_all(
834 &self,
835 var_name: &str,
836 def_file_path: &Path,
837 _position: (usize, usize),
838 ) -> Result<RefactorResult, RefactorError> {
839 if var_name.is_empty() {
840 return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
841 }
842
843 let (sigil, bare) = normalize_var(var_name);
844 let key = SymbolKey {
845 pkg: Arc::from("main".to_string()),
846 name: Arc::from(bare.to_string()),
847 sigil,
848 kind: SymKind::Var,
849 };
850
851 let def_uri = fs_path_to_uri(def_file_path).map_err(|e| {
852 RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
853 })?;
854 let store = self._index.document_store();
855 let def_doc = store.get(&def_uri).ok_or_else(|| {
856 RefactorError::DocumentNotIndexed(def_file_path.display().to_string())
857 })?;
858
859 let def_line_idx = def_doc
860 .text
861 .lines()
862 .position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
863 .ok_or_else(|| RefactorError::SymbolNotFound {
864 symbol: var_name.to_string(),
865 file: def_file_path.display().to_string(),
866 })?;
867
868 let def_line = def_doc.text.lines().nth(def_line_idx).unwrap_or("");
869
870 let expr = def_line
871 .split('=')
872 .nth(1)
873 .map(|s| s.trim().trim_end_matches(';'))
874 .ok_or_else(|| {
875 RefactorError::ParseError(format!(
876 "Variable '{}' has no initializer in line: {}",
877 var_name, def_line
878 ))
879 })?
880 .to_string();
881
882 let mut warnings = Vec::new();
883
884 if expr.contains('(') && expr.contains(')') {
885 warnings.push(format!(
886 "Warning: Initializer '{}' may contain function calls or side effects",
887 expr
888 ));
889 }
890
891 let mut all_locations = self._index.find_refs(&key);
892
893 if let Some(def_loc) = self._index.find_def(&key) {
894 if !all_locations.iter().any(|loc| loc.uri == def_loc.uri && loc.range == def_loc.range)
895 {
896 all_locations.push(def_loc);
897 }
898 }
899
900 if all_locations.is_empty() {
901 for doc in store.all_documents() {
902 if !doc.text.contains(var_name) {
903 continue;
904 }
905
906 let idx = doc.line_index.clone();
907 let mut pos = 0;
908
909 while let Some(found) = doc.text[pos..].find(var_name) {
910 let start = pos + found;
911 let end = start + var_name.len();
912
913 if start >= doc.text.len() || end > doc.text.len() {
914 break;
915 }
916
917 let (start_line, start_col) = idx.offset_to_position(start);
918 let (end_line, end_col) = idx.offset_to_position(end);
919 let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
920 let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
921
922 all_locations.push(crate::workspace_index::Location {
923 uri: doc.uri.clone(),
924 range: crate::position::Range {
925 start: crate::position::Position {
926 byte: start_byte,
927 line: start_line,
928 column: start_col,
929 },
930 end: crate::position::Position {
931 byte: end_byte,
932 line: end_line,
933 column: end_col,
934 },
935 },
936 });
937 pos = end;
938
939 if all_locations.len() >= 1000 {
940 warnings.push(
941 "Warning: More than 1000 occurrences found, limiting results"
942 .to_string(),
943 );
944 break;
945 }
946 }
947
948 if all_locations.len() >= 1000 {
949 break;
950 }
951 }
952 }
953
954 let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
955 let mut total_occurrences = 0;
956 let mut files_affected = std::collections::HashSet::new();
957
958 for loc in all_locations {
959 let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
960 RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
961 })?;
962
963 files_affected.insert(path.clone());
964
965 if let Some(doc) = store.get(&loc.uri) {
966 let start_off =
967 doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
968 let end_off =
969 doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
970
971 if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
972 let is_definition = doc.uri == def_uri
973 && doc.text[start_off.saturating_sub(10)..start_off.min(doc.text.len())]
974 .contains("my ");
975
976 if is_definition {
977 let line_start =
978 doc.text[..start_off].rfind('\n').map(|p| p + 1).unwrap_or(0);
979 let line_end = doc.text[end_off..]
980 .find('\n')
981 .map(|p| end_off + p + 1)
982 .unwrap_or(doc.text.len());
983
984 edits_by_file.entry(path).or_default().push(TextEdit {
985 start: line_start,
986 end: line_end,
987 new_text: String::new(),
988 });
989 } else {
990 edits_by_file.entry(path).or_default().push(TextEdit {
991 start: start_off,
992 end: end_off,
993 new_text: expr.clone(),
994 });
995 total_occurrences += 1;
996 }
997 }
998 }
999 }
1000
1001 let file_edits: Vec<FileEdit> = edits_by_file
1002 .into_iter()
1003 .map(|(file_path, edits)| FileEdit { file_path, edits })
1004 .collect();
1005
1006 let description = format!(
1007 "Inline variable '{}' across workspace: {} occurrences in {} files",
1008 var_name,
1009 total_occurrences,
1010 files_affected.len()
1011 );
1012
1013 Ok(RefactorResult { file_edits, description, warnings })
1014 }
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019 use super::*;
1020 use tempfile::{TempDir, tempdir};
1021
1022 fn setup_index(
1023 files: Vec<(&str, &str)>,
1024 ) -> Result<(TempDir, WorkspaceIndex, Vec<PathBuf>), Box<dyn std::error::Error>> {
1025 let dir = tempdir()?;
1026 let mut paths = Vec::new();
1027 let index = WorkspaceIndex::new();
1028 for (name, content) in files {
1029 let path = dir.path().join(name);
1030 std::fs::write(&path, content)?;
1031 let path_str = path.to_str().ok_or_else(|| {
1032 format!("Failed to convert path to string for test file: {}", name)
1033 })?;
1034 index.index_file_str(path_str, content)?;
1035 paths.push(path);
1036 }
1037 Ok((dir, index, paths))
1038 }
1039
1040 #[test]
1041 fn test_rename_symbol() -> Result<(), Box<dyn std::error::Error>> {
1042 let (_dir, index, paths) =
1043 setup_index(vec![("a.pl", "my $foo = 1; print $foo;"), ("b.pl", "print $foo;")])?;
1044 let refactor = WorkspaceRefactor::new(index);
1045 let result = refactor.rename_symbol("$foo", "$bar", &paths[0], (0, 0))?;
1046 assert!(!result.file_edits.is_empty());
1047 Ok(())
1048 }
1049
1050 #[test]
1051 fn test_extract_module() -> Result<(), Box<dyn std::error::Error>> {
1052 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1053 let refactor = WorkspaceRefactor::new(index);
1054 let res = refactor.extract_module(&paths[0], 2, 2, "Extracted")?;
1055 assert_eq!(res.file_edits.len(), 2);
1056 Ok(())
1057 }
1058
1059 #[test]
1060 fn test_extract_module_qualified_name_uses_nested_path()
1061 -> Result<(), Box<dyn std::error::Error>> {
1062 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1063 let refactor = WorkspaceRefactor::new(index);
1064 let res = refactor.extract_module(&paths[0], 2, 2, "My::Extracted")?;
1065 assert_eq!(res.file_edits.len(), 2);
1066 assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("My/Extracted.pm"));
1067 Ok(())
1068 }
1069
1070 #[test]
1071 fn test_optimize_imports() -> Result<(), Box<dyn std::error::Error>> {
1072 let (_dir, index, _paths) = setup_index(vec![
1073 ("a.pl", "use B;\nuse A;\nuse B;\n"),
1074 ("b.pl", "use C;\nuse A;\nuse C;\n"),
1075 ])?;
1076 let refactor = WorkspaceRefactor::new(index);
1077 let res = refactor.optimize_imports()?;
1078 assert_eq!(res.file_edits.len(), 2);
1079 Ok(())
1080 }
1081
1082 #[test]
1083 fn test_move_subroutine() -> Result<(), Box<dyn std::error::Error>> {
1084 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1085 let refactor = WorkspaceRefactor::new(index);
1086 let res = refactor.move_subroutine("foo", &paths[0], "b")?;
1087 assert_eq!(res.file_edits.len(), 2);
1088 Ok(())
1089 }
1090
1091 #[test]
1092 fn test_move_subroutine_qualified_target_uses_nested_path()
1093 -> Result<(), Box<dyn std::error::Error>> {
1094 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1095 let refactor = WorkspaceRefactor::new(index);
1096 let res = refactor.move_subroutine("foo", &paths[0], "Target::Module")?;
1097 assert_eq!(res.file_edits.len(), 2);
1098 assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("Target/Module.pm"));
1099 Ok(())
1100 }
1101
1102 #[test]
1103 fn test_inline_variable() -> Result<(), Box<dyn std::error::Error>> {
1104 let (_dir, index, paths) =
1105 setup_index(vec![("a.pl", "my $x = 42;\nmy $y = $x + 1;\nprint $y;\n")])?;
1106 let refactor = WorkspaceRefactor::new(index);
1107 let result = refactor.inline_variable("$x", &paths[0], (0, 0))?;
1108 assert!(!result.file_edits.is_empty());
1109 Ok(())
1110 }
1111
1112 #[test]
1114 fn test_rename_symbol_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1115 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $foo = 1;")])?;
1116 let refactor = WorkspaceRefactor::new(index);
1117
1118 assert!(matches!(
1120 refactor.rename_symbol("", "$bar", &paths[0], (0, 0)),
1121 Err(RefactorError::InvalidInput(_))
1122 ));
1123
1124 assert!(matches!(
1126 refactor.rename_symbol("$foo", "", &paths[0], (0, 0)),
1127 Err(RefactorError::InvalidInput(_))
1128 ));
1129
1130 assert!(matches!(
1132 refactor.rename_symbol("$foo", "$foo", &paths[0], (0, 0)),
1133 Err(RefactorError::InvalidInput(_))
1134 ));
1135 Ok(())
1136 }
1137
1138 #[test]
1139 fn test_extract_module_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1140 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1141 let refactor = WorkspaceRefactor::new(index);
1142
1143 assert!(matches!(
1145 refactor.extract_module(&paths[0], 1, 2, ""),
1146 Err(RefactorError::InvalidInput(_))
1147 ));
1148
1149 assert!(matches!(
1151 refactor.extract_module(&paths[0], 5, 2, "Test"),
1152 Err(RefactorError::InvalidInput(_))
1153 ));
1154 Ok(())
1155 }
1156
1157 #[test]
1158 fn test_move_subroutine_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1159 let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo { 1 }")])?;
1160 let refactor = WorkspaceRefactor::new(index);
1161
1162 assert!(matches!(
1164 refactor.move_subroutine("", &paths[0], "Utils"),
1165 Err(RefactorError::InvalidInput(_))
1166 ));
1167
1168 assert!(matches!(
1170 refactor.move_subroutine("foo", &paths[0], ""),
1171 Err(RefactorError::InvalidInput(_))
1172 ));
1173 Ok(())
1174 }
1175
1176 #[test]
1177 fn test_inline_variable_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1178 let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 42;")])?;
1179 let refactor = WorkspaceRefactor::new(index);
1180
1181 assert!(matches!(
1183 refactor.inline_variable("", &paths[0], (0, 0)),
1184 Err(RefactorError::InvalidInput(_))
1185 ));
1186 Ok(())
1187 }
1188
1189 #[test]
1191 fn test_rename_symbol_unicode_variables() -> Result<(), Box<dyn std::error::Error>> {
1192 let (_dir, index, paths) = setup_index(vec![
1193 ("unicode.pl", "my $♥ = '爱'; print $♥; # Unicode variable"),
1194 ("unicode2.pl", "use utf8; my $données = 42; print $données;"), ])?;
1196 let refactor = WorkspaceRefactor::new(index);
1197
1198 let result = refactor.rename_symbol("$♥", "$love", &paths[0], (0, 0))?;
1200 assert!(!result.file_edits.is_empty());
1201 assert!(result.description.contains("♥"));
1202
1203 let result = refactor.rename_symbol("$données", "$data", &paths[1], (0, 0))?;
1205 assert!(!result.file_edits.is_empty());
1206 assert!(result.description.contains("données"));
1207 Ok(())
1208 }
1209
1210 #[test]
1211 fn test_extract_module_unicode_content() -> Result<(), Box<dyn std::error::Error>> {
1212 let (_dir, index, paths) = setup_index(vec![(
1213 "unicode_content.pl",
1214 "# コメント in Japanese\nmy $message = \"你好世界\";\nprint $message;\n# More 中文 content\n",
1215 )])?;
1216 let refactor = WorkspaceRefactor::new(index);
1217
1218 let result = refactor.extract_module(&paths[0], 2, 3, "UnicodeUtils")?;
1219 assert_eq!(result.file_edits.len(), 2); let new_module_edit = &result.file_edits[1];
1223 assert!(new_module_edit.edits[0].new_text.contains("你好世界"));
1224 Ok(())
1225 }
1226
1227 #[test]
1228 fn test_inline_variable_unicode_expressions() -> Result<(), Box<dyn std::error::Error>> {
1229 let (_dir, index, paths) = setup_index(vec![(
1230 "unicode_expr.pl",
1231 "my $表达式 = \"测试表达式\";\nmy $result = $表达式 . \"suffix\";\nprint $result;\n",
1232 )])?;
1233 let refactor = WorkspaceRefactor::new(index);
1234
1235 let result = refactor.inline_variable("$表达式", &paths[0], (0, 0))?;
1236 assert!(!result.file_edits.is_empty());
1237
1238 let edits = &result.file_edits[0].edits;
1240 assert!(edits.iter().any(|edit| edit.new_text.contains("测试表达式")));
1241 Ok(())
1242 }
1243
1244 #[test]
1246 fn test_rename_symbol_complex_perl_constructs() -> Result<(), Box<dyn std::error::Error>> {
1247 let (_dir, index, paths) = setup_index(vec![(
1248 "complex.pl",
1249 r#"
1250package MyPackage;
1251my @array = qw($var1 $var2 $var3);
1252my %hash = ( key1 => $var1, key2 => $var2 );
1253my $ref = \$var1;
1254print "Variable in string: $var1\n";
1255$var1 =~ s/old/new/g;
1256for my $item (@{[$var1, $var2]}) {
1257 print $item;
1258}
1259"#,
1260 )])?;
1261 let refactor = WorkspaceRefactor::new(index);
1262
1263 let result = refactor.rename_symbol("$var1", "$renamed_var", &paths[0], (0, 0))?;
1264 assert!(!result.file_edits.is_empty());
1265
1266 let edits = &result.file_edits[0].edits;
1268 assert!(edits.len() >= 3);
1269 Ok(())
1270 }
1271
1272 #[test]
1273 fn test_extract_module_with_dependencies() -> Result<(), Box<dyn std::error::Error>> {
1274 let (_dir, index, paths) = setup_index(vec![(
1275 "with_deps.pl",
1276 r#"
1277use strict;
1278use warnings;
1279
1280sub utility_func {
1281 my ($param) = @_;
1282 return "utility result";
1283}
1284
1285sub main_func {
1286 my $data = "test data";
1287 my $result = utility_func($data);
1288 print $result;
1289}
1290"#,
1291 )])?;
1292 let refactor = WorkspaceRefactor::new(index);
1293
1294 let result = refactor.extract_module(&paths[0], 5, 8, "Utils")?;
1295 assert_eq!(result.file_edits.len(), 2);
1296
1297 let new_module_edit = &result.file_edits[1];
1299 assert!(new_module_edit.edits[0].new_text.contains("sub utility_func"));
1300 assert!(new_module_edit.edits[0].new_text.contains("utility result"));
1301 Ok(())
1302 }
1303
1304 #[test]
1305 fn test_optimize_imports_complex_scenarios() -> Result<(), Box<dyn std::error::Error>> {
1306 let (_dir, index, _paths) = setup_index(vec![
1307 (
1308 "complex_imports.pl",
1309 r#"
1310use strict;
1311use warnings;
1312use utf8;
1313use JSON;
1314use JSON qw(encode_json);
1315use YAML;
1316use YAML qw(Load);
1317use JSON; # Duplicate
1318"#,
1319 ),
1320 ("minimal_imports.pl", "use strict;\nuse warnings;"),
1321 ("no_imports.pl", "print 'Hello World';"),
1322 ])?;
1323 let refactor = WorkspaceRefactor::new(index);
1324
1325 let result = refactor.optimize_imports()?;
1326
1327 assert!(result.file_edits.len() <= 3);
1329
1330 for file_edit in &result.file_edits {
1332 assert!(!file_edit.edits.is_empty());
1333 }
1334 Ok(())
1335 }
1336
1337 #[test]
1338 fn test_move_subroutine_not_found() -> Result<(), Box<dyn std::error::Error>> {
1339 let (_dir, index, paths) = setup_index(vec![("empty.pl", "# No subroutines here")])?;
1340 let refactor = WorkspaceRefactor::new(index);
1341
1342 let result = refactor.move_subroutine("nonexistent", &paths[0], "Target");
1343 assert!(matches!(result, Err(RefactorError::SymbolNotFound { .. })));
1344 Ok(())
1345 }
1346
1347 #[test]
1348 fn test_inline_variable_no_initializer() -> Result<(), Box<dyn std::error::Error>> {
1349 let (_dir, index, paths) =
1350 setup_index(vec![("no_init.pl", "my $var;\n$var = 42;\nprint $var;\n")])?;
1351 let refactor = WorkspaceRefactor::new(index);
1352
1353 let result = refactor.inline_variable("$var", &paths[0], (0, 0));
1354 assert!(matches!(result, Err(RefactorError::ParseError(_))));
1356 Ok(())
1357 }
1358
1359 #[test]
1360 fn test_import_optimization_integration() -> Result<(), Box<dyn std::error::Error>> {
1361 let (_dir, index, _paths) = setup_index(vec![
1363 (
1364 "with_unused.pl",
1365 "use strict;\nuse warnings;\nuse JSON qw(encode_json unused_symbol);\n\nmy $json = encode_json('test');",
1366 ),
1367 ("clean.pl", "use strict;\nuse warnings;\n\nprint 'test';"),
1368 ])?;
1369 let refactor = WorkspaceRefactor::new(index);
1370
1371 let result = refactor.optimize_imports()?;
1372
1373 assert!(!result.file_edits.is_empty());
1376
1377 let has_optimizations = result.file_edits.iter().any(|edit| !edit.edits.is_empty());
1379 assert!(has_optimizations);
1380 Ok(())
1381 }
1382
1383 #[test]
1385 fn test_large_file_handling() -> Result<(), Box<dyn std::error::Error>> {
1386 let mut large_content = String::new();
1388 large_content.push_str("my $target = 'value';\n");
1389 for i in 0..100 {
1390 large_content.push_str(&format!("print $target; # Line {}\n", i));
1391 }
1392
1393 let (_dir, index, paths) = setup_index(vec![("large.pl", &large_content)])?;
1394 let refactor = WorkspaceRefactor::new(index);
1395
1396 let result = refactor.rename_symbol("$target", "$renamed", &paths[0], (0, 0))?;
1397 assert!(!result.file_edits.is_empty());
1398
1399 let edits = &result.file_edits[0].edits;
1401 assert_eq!(edits.len(), 101);
1402 Ok(())
1403 }
1404
1405 #[test]
1406 fn test_multiple_files_workspace() -> Result<(), Box<dyn std::error::Error>> {
1407 let files = (0..10)
1408 .map(|i| (format!("file_{}.pl", i), format!("my $shared = {}; print $shared;\n", i)))
1409 .collect::<Vec<_>>();
1410
1411 let files_refs: Vec<_> =
1412 files.iter().map(|(name, content)| (name.as_str(), content.as_str())).collect();
1413 let (_dir, index, paths) = setup_index(files_refs)?;
1414 let refactor = WorkspaceRefactor::new(index);
1415
1416 let result = refactor.rename_symbol("$shared", "$common", &paths[0], (0, 0))?;
1417 assert!(!result.file_edits.is_empty());
1418
1419 assert!(!result.description.is_empty());
1421 Ok(())
1422 }
1423
1424 #[test]
1426 fn inline_multi_file_basic() -> Result<(), Box<dyn std::error::Error>> {
1427 let (_dir, index, paths) = setup_index(vec![
1429 ("a.pl", "my $const = 42;\nprint $const;\n"),
1430 ("b.pl", "print $const;\n"),
1431 ("c.pl", "my $result = $const + 1;\n"),
1432 ])?;
1433 let refactor = WorkspaceRefactor::new(index);
1434 let result = refactor.inline_variable_all("$const", &paths[0], (0, 0))?;
1435
1436 assert!(!result.file_edits.is_empty());
1438 assert!(result.description.contains("workspace"));
1439 Ok(())
1440 }
1441
1442 #[test]
1444 fn inline_multi_file_validates_constant() -> Result<(), Box<dyn std::error::Error>> {
1445 let (_dir, index, paths) =
1447 setup_index(vec![("a.pl", "my $x = get_value();\nprint $x;\n")])?;
1448 let refactor = WorkspaceRefactor::new(index);
1449
1450 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1452 assert!(!result.file_edits.is_empty());
1453 assert!(!result.warnings.is_empty(), "Should have warning about function call");
1455 Ok(())
1456 }
1457
1458 #[test]
1460 fn inline_multi_file_respects_scope() -> Result<(), Box<dyn std::error::Error>> {
1461 let (_dir, index, paths) = setup_index(vec![
1463 ("a.pl", "package A;\nmy $pkg_var = 10;\nprint $pkg_var;\n"),
1464 ("b.pl", "package B;\nmy $pkg_var = 20;\nprint $pkg_var;\n"),
1465 ])?;
1466 let refactor = WorkspaceRefactor::new(index);
1467
1468 let result = refactor.inline_variable("$pkg_var", &paths[0], (0, 0))?;
1470 assert!(!result.file_edits.is_empty());
1471 Ok(())
1472 }
1473
1474 #[test]
1476 fn inline_multi_file_supports_all_types() -> Result<(), Box<dyn std::error::Error>> {
1477 let (_dir, index, paths) = setup_index(vec![("scalar.pl", "my $x = 42;\nprint $x;\n")])?;
1479 let refactor = WorkspaceRefactor::new(index);
1480
1481 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1483 assert!(!result.file_edits.is_empty());
1484
1485 Ok(())
1486 }
1487
1488 #[test]
1490 fn inline_multi_file_reports_occurrences() -> Result<(), Box<dyn std::error::Error>> {
1491 let (_dir, index, paths) = setup_index(vec![
1493 ("a.pl", "my $x = 42;\nprint $x;\nprint $x;\nprint $x;\n"),
1494 ("b.pl", "print $x;\nprint $x;\n"),
1495 ])?;
1496 let refactor = WorkspaceRefactor::new(index);
1497 let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1498
1499 assert!(
1501 result.description.contains("occurrence") || result.description.contains("workspace")
1502 );
1503 Ok(())
1504 }
1505}