1use crate::refactor::refactoring::BackupInfo;
51use crate::refactor::workspace_refactor::{FileEdit, TextEdit};
52use crate::workspace_index::WorkspaceIndex;
53use perl_qualified_name::split_qualified_name;
54use serde::{Deserialize, Serialize};
55use std::collections::{BTreeMap, HashMap};
56use std::path::{Path, PathBuf};
57use std::time::Instant;
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct WorkspaceRenameConfig {
62 pub atomic_mode: bool,
64
65 pub create_backups: bool,
67
68 pub operation_timeout: u64,
70
71 pub parallel_processing: bool,
73
74 pub batch_size: usize,
76
77 pub max_files: usize,
79
80 pub report_progress: bool,
82
83 pub validate_syntax: bool,
85
86 pub follow_symlinks: bool,
88}
89
90impl Default for WorkspaceRenameConfig {
91 fn default() -> Self {
92 Self {
93 atomic_mode: true,
94 create_backups: true,
95 operation_timeout: 60,
96 parallel_processing: true,
97 batch_size: 10,
98 max_files: 0,
99 report_progress: true,
100 validate_syntax: true,
101 follow_symlinks: false,
102 }
103 }
104}
105
106#[derive(Debug, Serialize, Deserialize)]
108pub struct WorkspaceRenameResult {
109 pub file_edits: Vec<FileEdit>,
111 pub backup_info: Option<BackupInfo>,
113 pub description: String,
115 pub warnings: Vec<String>,
117 pub statistics: RenameStatistics,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct RenameStatistics {
124 pub files_modified: usize,
126 pub total_changes: usize,
128 pub elapsed_ms: u64,
130}
131
132#[derive(Debug, Clone)]
134pub enum Progress {
135 Scanning {
137 total: usize,
139 },
140 Processing {
142 current: usize,
144 total: usize,
146 file: PathBuf,
148 },
149 Complete {
151 files_modified: usize,
153 changes: usize,
155 },
156}
157
158#[derive(Debug, Clone)]
160pub enum WorkspaceRenameError {
161 SymbolNotFound {
163 symbol: String,
165 file: String,
167 },
168
169 NameConflict {
171 new_name: String,
173 conflicts: Vec<ConflictLocation>,
175 },
176
177 Timeout {
179 elapsed_seconds: u64,
181 files_processed: usize,
183 total_files: usize,
185 },
186
187 FileSystemError {
189 operation: String,
191 file: PathBuf,
193 error: String,
195 },
196
197 RollbackFailed {
199 original_error: String,
201 rollback_error: String,
203 backup_dir: PathBuf,
205 },
206
207 IndexUpdateFailed {
209 error: String,
211 affected_files: Vec<PathBuf>,
213 },
214
215 SecurityError {
217 message: String,
219 path: Option<PathBuf>,
221 },
222
223 NotImplemented {
225 feature: String,
227 },
228}
229
230impl std::fmt::Display for WorkspaceRenameError {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 match self {
233 WorkspaceRenameError::SymbolNotFound { symbol, file } => {
234 write!(f, "Symbol '{}' not found in {}", symbol, file)
235 }
236 WorkspaceRenameError::NameConflict { new_name, conflicts } => {
237 write!(f, "Name '{}' conflicts with {} existing symbols", new_name, conflicts.len())
238 }
239 WorkspaceRenameError::Timeout { elapsed_seconds, files_processed, total_files } => {
240 write!(
241 f,
242 "Operation timed out after {}s ({}/{} files)",
243 elapsed_seconds, files_processed, total_files
244 )
245 }
246 WorkspaceRenameError::FileSystemError { operation, file, error } => {
247 write!(f, "File system error during {}: {} - {}", operation, file.display(), error)
248 }
249 WorkspaceRenameError::RollbackFailed { original_error, rollback_error, backup_dir } => {
250 write!(
251 f,
252 "Rollback failed - original: {}, rollback: {}, backup: {}",
253 original_error,
254 rollback_error,
255 backup_dir.display()
256 )
257 }
258 WorkspaceRenameError::IndexUpdateFailed { error, affected_files } => {
259 write!(f, "Index update failed: {} ({} files)", error, affected_files.len())
260 }
261 WorkspaceRenameError::SecurityError { message, path } => {
262 if let Some(p) = path {
263 write!(f, "Security error: {} ({})", message, p.display())
264 } else {
265 write!(f, "Security error: {}", message)
266 }
267 }
268 WorkspaceRenameError::NotImplemented { feature } => {
269 write!(f, "Feature not yet implemented: {}", feature)
270 }
271 }
272 }
273}
274
275impl std::error::Error for WorkspaceRenameError {}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct ConflictLocation {
280 pub file: PathBuf,
282 pub line: u32,
284 pub column: u32,
286 pub existing_symbol: String,
288}
289
290pub struct WorkspaceRename {
295 index: WorkspaceIndex,
297 config: WorkspaceRenameConfig,
299}
300
301impl WorkspaceRename {
302 pub fn new(index: WorkspaceIndex, config: WorkspaceRenameConfig) -> Self {
311 Self { index, config }
312 }
313
314 pub fn index(&self) -> &WorkspaceIndex {
316 &self.index
317 }
318
319 pub fn rename_symbol(
337 &self,
338 old_name: &str,
339 new_name: &str,
340 file_path: &Path,
341 _position: (usize, usize),
342 ) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
343 self.rename_symbol_impl(old_name, new_name, file_path, None)
344 }
345
346 pub fn rename_symbol_with_progress(
359 &self,
360 old_name: &str,
361 new_name: &str,
362 file_path: &Path,
363 _position: (usize, usize),
364 progress_tx: std::sync::mpsc::Sender<Progress>,
365 ) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
366 self.rename_symbol_impl(old_name, new_name, file_path, Some(progress_tx))
367 }
368
369 fn rename_symbol_impl(
371 &self,
372 old_name: &str,
373 new_name: &str,
374 file_path: &Path,
375 progress_tx: Option<std::sync::mpsc::Sender<Progress>>,
376 ) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
377 let start = Instant::now();
378 let timeout = std::time::Duration::from_secs(self.config.operation_timeout);
379
380 let (old_package, old_bare) = split_qualified_name(old_name);
382 let (_new_package, new_bare) = split_qualified_name(new_name);
383
384 self.check_name_conflicts(new_bare, old_package)?;
387
388 let definition = self.index.find_definition(old_name);
391
392 let scope_package = old_package.map(|p| p.to_string());
395
396 let mut all_references = self.index.find_references(old_name);
398
399 if let Some(_pkg) = &scope_package {
401 let qualified = format!("{}::{}", _pkg, old_bare);
402 let qualified_refs = self.index.find_references(&qualified);
403 for r in qualified_refs {
404 if !all_references
405 .iter()
406 .any(|existing| existing.uri == r.uri && existing.range == r.range)
407 {
408 all_references.push(r);
409 }
410 }
411 let bare_refs = self.index.find_references(old_bare);
413 for r in bare_refs {
414 if !all_references
415 .iter()
416 .any(|existing| existing.uri == r.uri && existing.range == r.range)
417 {
418 all_references.push(r);
419 }
420 }
421 }
422
423 if let Some(ref def) = definition {
425 if !all_references.iter().any(|r| r.uri == def.uri && r.range == def.range) {
426 all_references.push(def.clone());
427 }
428 }
429
430 let store = self.index.document_store();
432 let all_docs = store.all_documents();
433 let total_files = all_docs.len();
434
435 if let Some(ref tx) = progress_tx {
437 let _ = tx.send(Progress::Scanning { total: total_files });
438 }
439
440 let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
444 let mut files_processed = 0;
445
446 for (idx, doc) in all_docs.iter().enumerate() {
447 if start.elapsed() > timeout {
449 return Err(WorkspaceRenameError::Timeout {
450 elapsed_seconds: start.elapsed().as_secs(),
451 files_processed,
452 total_files,
453 });
454 }
455
456 if self.config.max_files > 0 && files_processed >= self.config.max_files {
458 break;
459 }
460
461 let doc_path = crate::workspace_index::uri_to_fs_path(&doc.uri);
462
463 if let Some(ref tx) = progress_tx {
465 let _ = tx.send(Progress::Processing {
466 current: idx + 1,
467 total: total_files,
468 file: doc_path.clone().unwrap_or_default(),
469 });
470 }
471
472 let text = &doc.text;
474 if !text.contains(old_bare) {
475 files_processed += 1;
476 continue;
477 }
478
479 let line_index = &doc.line_index;
480 let mut search_pos = 0;
481 let mut file_edits = Vec::new();
482
483 while let Some(found) = text[search_pos..].find(old_bare) {
484 let match_start = search_pos + found;
485 let match_end = match_start + old_bare.len();
486
487 if match_end > text.len() {
489 break;
490 }
491
492 let is_word_start =
494 match_start == 0 || !is_identifier_char(text.as_bytes()[match_start - 1]);
495 let is_word_end =
496 match_end >= text.len() || !is_identifier_char(text.as_bytes()[match_end]);
497
498 if is_word_start && is_word_end {
499 let in_scope = if let Some(ref pkg) = scope_package {
502 let before = &text[..match_start];
504 let is_qualified_with_pkg = before.ends_with(&format!("{}::", pkg));
505
506 let current_package = find_package_at_offset(text, match_start);
508 let in_package_scope = current_package.as_deref() == Some(pkg.as_str());
509
510 is_qualified_with_pkg || in_package_scope
511 } else {
512 true
513 };
514
515 if in_scope {
516 let (edit_start, replacement) = if let Some(ref pkg) = scope_package {
518 let prefix = format!("{}::", pkg);
519 if match_start >= prefix.len()
520 && text[match_start - prefix.len()..match_start] == *prefix
521 {
522 (match_start - prefix.len(), format!("{}::{}", pkg, new_bare))
524 } else {
525 (match_start, new_bare.to_string())
526 }
527 } else {
528 (match_start, new_bare.to_string())
529 };
530
531 let (start_line, start_col) = line_index.offset_to_position(edit_start);
532 let (end_line, end_col) = line_index.offset_to_position(match_end);
533
534 if let (Some(start_byte), Some(end_byte)) = (
535 line_index.position_to_offset(start_line, start_col),
536 line_index.position_to_offset(end_line, end_col),
537 ) {
538 file_edits.push(TextEdit {
539 start: start_byte,
540 end: end_byte,
541 new_text: replacement,
542 });
543 }
544 }
545 }
546
547 search_pos = match_end;
548
549 if file_edits.len() >= 1000 {
551 break;
552 }
553 }
554
555 if !file_edits.is_empty() {
556 if let Some(path) = doc_path {
557 edits_by_file.entry(path).or_default().extend(file_edits);
558 }
559 }
560
561 files_processed += 1;
562 }
563
564 if edits_by_file.is_empty() {
566 return Err(WorkspaceRenameError::SymbolNotFound {
567 symbol: old_name.to_string(),
568 file: file_path.display().to_string(),
569 });
570 }
571
572 let file_edits: Vec<FileEdit> = edits_by_file
574 .into_iter()
575 .map(|(file_path, mut edits)| {
576 edits.sort_by(|a, b| b.start.cmp(&a.start));
577 FileEdit { file_path, edits }
578 })
579 .collect();
580
581 let total_changes: usize = file_edits.iter().map(|fe| fe.edits.len()).sum();
582 let files_modified = file_edits.len();
583
584 let backup_info =
586 if self.config.create_backups { self.create_backup(&file_edits).ok() } else { None };
587
588 let elapsed_ms = start.elapsed().as_millis() as u64;
589
590 if let Some(ref tx) = progress_tx {
592 let _ = tx.send(Progress::Complete { files_modified, changes: total_changes });
593 }
594
595 Ok(WorkspaceRenameResult {
596 file_edits,
597 backup_info,
598 description: format!("Rename '{}' to '{}'", old_name, new_name),
599 warnings: vec![],
600 statistics: RenameStatistics { files_modified, total_changes, elapsed_ms },
601 })
602 }
603
604 fn check_name_conflicts(
606 &self,
607 new_bare_name: &str,
608 scope_package: Option<&str>,
609 ) -> Result<(), WorkspaceRenameError> {
610 let all_symbols = self.index.all_symbols();
611
612 let mut conflicts = Vec::new();
613 for symbol in &all_symbols {
614 let matches_bare = symbol.name == new_bare_name;
615 let matches_qualified = if let Some(pkg) = scope_package {
616 let qualified = format!("{}::{}", pkg, new_bare_name);
617 symbol.qualified_name.as_deref() == Some(&qualified) || symbol.name == qualified
618 } else {
619 false
620 };
621
622 if matches_bare || matches_qualified {
623 conflicts.push(ConflictLocation {
624 file: crate::workspace_index::uri_to_fs_path(&symbol.uri).unwrap_or_default(),
625 line: symbol.range.start.line,
626 column: symbol.range.start.column,
627 existing_symbol: symbol
628 .qualified_name
629 .clone()
630 .unwrap_or_else(|| symbol.name.clone()),
631 });
632 }
633 }
634
635 if conflicts.is_empty() {
636 Ok(())
637 } else {
638 Err(WorkspaceRenameError::NameConflict {
639 new_name: new_bare_name.to_string(),
640 conflicts,
641 })
642 }
643 }
644
645 fn create_backup(&self, file_edits: &[FileEdit]) -> Result<BackupInfo, WorkspaceRenameError> {
647 let ts =
649 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
650 let backup_dir = std::env::temp_dir().join(format!(
651 "perl_rename_backup_{}_{}_{:?}",
652 ts.as_secs(),
653 ts.subsec_nanos(),
654 std::thread::current().id()
655 ));
656
657 std::fs::create_dir_all(&backup_dir).map_err(|e| {
658 WorkspaceRenameError::FileSystemError {
659 operation: "create_backup_dir".to_string(),
660 file: backup_dir.clone(),
661 error: e.to_string(),
662 }
663 })?;
664
665 let mut file_mappings = HashMap::new();
666
667 for (idx, file_edit) in file_edits.iter().enumerate() {
668 if file_edit.file_path.exists() {
669 let file_name = file_edit
671 .file_path
672 .file_name()
673 .unwrap_or_default()
674 .to_string_lossy()
675 .to_string();
676 let backup_name = format!("{}_{}", idx, file_name);
677 let backup_path = backup_dir.join(&backup_name);
678
679 std::fs::copy(&file_edit.file_path, &backup_path).map_err(|e| {
680 WorkspaceRenameError::FileSystemError {
681 operation: "backup_copy".to_string(),
682 file: file_edit.file_path.clone(),
683 error: e.to_string(),
684 }
685 })?;
686
687 file_mappings.insert(file_edit.file_path.clone(), backup_path);
688 }
689 }
690
691 Ok(BackupInfo { backup_dir, file_mappings })
692 }
693
694 pub fn apply_edits(&self, result: &WorkspaceRenameResult) -> Result<(), WorkspaceRenameError> {
698 let mut written_files = Vec::new();
699
700 for file_edit in &result.file_edits {
701 let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
703 if let Some(ref backup) = result.backup_info {
705 let _ = self.rollback_from_backup(&written_files, backup);
706 }
707 WorkspaceRenameError::FileSystemError {
708 operation: "read".to_string(),
709 file: file_edit.file_path.clone(),
710 error: e.to_string(),
711 }
712 })?;
713
714 let mut new_content = content;
716 for edit in &file_edit.edits {
717 if edit.start <= new_content.len() && edit.end <= new_content.len() {
718 new_content = format!(
719 "{}{}{}",
720 &new_content[..edit.start],
721 edit.new_text,
722 &new_content[edit.end..],
723 );
724 }
725 }
726
727 std::fs::write(&file_edit.file_path, &new_content).map_err(|e| {
729 if let Some(ref backup) = result.backup_info {
731 let _ = self.rollback_from_backup(&written_files, backup);
732 }
733 WorkspaceRenameError::FileSystemError {
734 operation: "write".to_string(),
735 file: file_edit.file_path.clone(),
736 error: e.to_string(),
737 }
738 })?;
739
740 written_files.push(file_edit.file_path.clone());
741 }
742
743 Ok(())
744 }
745
746 fn rollback_from_backup(
748 &self,
749 files: &[PathBuf],
750 backup: &BackupInfo,
751 ) -> Result<(), WorkspaceRenameError> {
752 for file in files {
753 if let Some(backup_path) = backup.file_mappings.get(file) {
754 std::fs::copy(backup_path, file).map_err(|e| {
755 WorkspaceRenameError::RollbackFailed {
756 original_error: "file write failed".to_string(),
757 rollback_error: format!("failed to restore {}: {}", file.display(), e),
758 backup_dir: backup.backup_dir.clone(),
759 }
760 })?;
761 }
762 }
763 Ok(())
764 }
765
766 pub fn update_index_after_rename(
770 &self,
771 old_name: &str,
772 new_name: &str,
773 file_edits: &[FileEdit],
774 ) -> Result<(), WorkspaceRenameError> {
775 for file_edit in file_edits {
777 let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
778 WorkspaceRenameError::IndexUpdateFailed {
779 error: format!("Failed to read {}: {}", file_edit.file_path.display(), e),
780 affected_files: vec![file_edit.file_path.clone()],
781 }
782 })?;
783
784 let uri_str =
785 crate::workspace_index::fs_path_to_uri(&file_edit.file_path).map_err(|e| {
786 WorkspaceRenameError::IndexUpdateFailed {
787 error: format!("URI conversion failed: {}", e),
788 affected_files: vec![file_edit.file_path.clone()],
789 }
790 })?;
791
792 self.index.remove_file(&uri_str);
794
795 let url =
796 url::Url::parse(&uri_str).map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
797 error: format!("URL parse failed: {}", e),
798 affected_files: vec![file_edit.file_path.clone()],
799 })?;
800
801 self.index.index_file(url, content).map_err(|e| {
802 WorkspaceRenameError::IndexUpdateFailed {
803 error: format!(
804 "Re-indexing failed for '{}' -> '{}': {}",
805 old_name, new_name, e
806 ),
807 affected_files: vec![file_edit.file_path.clone()],
808 }
809 })?;
810 }
811
812 Ok(())
813 }
814}
815
816fn is_identifier_char(b: u8) -> bool {
818 b.is_ascii_alphanumeric() || b == b'_'
819}
820
821fn find_package_at_offset(text: &str, offset: usize) -> Option<String> {
823 let before = &text[..offset];
824 let mut last_package = None;
826 let mut search_pos = 0;
827 while let Some(found) = before[search_pos..].find("package ") {
828 let pkg_start = search_pos + found + "package ".len();
829 let remaining = &before[pkg_start..];
831 let pkg_end = remaining
832 .find(|c: char| c == ';' || c == '{' || c.is_whitespace())
833 .unwrap_or(remaining.len());
834 let pkg_name = remaining[..pkg_end].trim();
835 if !pkg_name.is_empty() {
836 last_package = Some(pkg_name.to_string());
837 }
838 search_pos = pkg_start;
839 }
840 last_package
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 #[test]
848 fn test_config_defaults() {
849 let config = WorkspaceRenameConfig::default();
850 assert!(config.atomic_mode);
851 assert!(config.create_backups);
852 assert_eq!(config.operation_timeout, 60);
853 assert!(config.parallel_processing);
854 assert_eq!(config.batch_size, 10);
855 assert_eq!(config.max_files, 0);
856 assert!(config.report_progress);
857 assert!(config.validate_syntax);
858 assert!(!config.follow_symlinks);
859 }
860
861 #[test]
862 fn test_split_qualified_name() {
863 assert_eq!(split_qualified_name("process"), (None, "process"));
864 assert_eq!(split_qualified_name("Utils::process"), (Some("Utils"), "process"));
865 assert_eq!(split_qualified_name("A::B::process"), (Some("A::B"), "process"));
866 }
867
868 #[test]
869 fn test_is_identifier_char() {
870 assert!(is_identifier_char(b'a'));
871 assert!(is_identifier_char(b'Z'));
872 assert!(is_identifier_char(b'0'));
873 assert!(is_identifier_char(b'_'));
874 assert!(!is_identifier_char(b' '));
875 assert!(!is_identifier_char(b':'));
876 assert!(!is_identifier_char(b';'));
877 }
878
879 #[test]
880 fn test_find_package_at_offset() {
881 let text = "package Foo;\nsub bar { 1 }\npackage Bar;\nsub baz { 2 }\n";
882 assert_eq!(find_package_at_offset(text, 20), Some("Foo".to_string()));
883 assert_eq!(find_package_at_offset(text, 45), Some("Bar".to_string()));
884 assert_eq!(find_package_at_offset(text, 0), None);
885 }
886}