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