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 = is_word_boundary_before(text, match_start);
492 let is_word_end = is_word_boundary_after(text, match_end);
493
494 if is_word_start
499 && is_word_end
500 && (is_rename_code_position(text, match_start)
501 || is_interpolated_in_double_quote(text, match_start))
502 {
503 let in_scope = if let Some(ref pkg) = scope_package {
506 let before = &text[..match_start];
508 let is_qualified_with_pkg = before.ends_with(&format!("{}::", pkg));
509
510 let current_package = find_package_at_offset(text, match_start);
512 let in_package_scope = current_package.as_deref() == Some(pkg.as_str());
513
514 is_qualified_with_pkg || in_package_scope
515 } else {
516 true
517 };
518
519 if in_scope {
520 let (edit_start, replacement) = if let Some(ref pkg) = scope_package {
522 let prefix = format!("{}::", pkg);
523 if match_start >= prefix.len()
524 && text[match_start - prefix.len()..match_start] == *prefix
525 {
526 (match_start - prefix.len(), format!("{}::{}", pkg, new_bare))
528 } else {
529 (match_start, new_bare.to_string())
530 }
531 } else {
532 (match_start, new_bare.to_string())
533 };
534
535 let (start_line, start_col) = line_index.offset_to_position(edit_start);
536 let (end_line, end_col) = line_index.offset_to_position(match_end);
537
538 if let (Some(start_byte), Some(end_byte)) = (
539 line_index.position_to_offset(start_line, start_col),
540 line_index.position_to_offset(end_line, end_col),
541 ) {
542 file_edits.push(TextEdit {
543 start: start_byte,
544 end: end_byte,
545 new_text: replacement,
546 });
547 }
548 }
549 }
550
551 search_pos = match_end;
552
553 if file_edits.len() >= 1000 {
555 break;
556 }
557 }
558
559 if !file_edits.is_empty() {
560 if let Some(path) = doc_path {
561 edits_by_file.entry(path).or_default().extend(file_edits);
562 }
563 }
564
565 files_processed += 1;
566 }
567
568 if edits_by_file.is_empty() {
570 return Err(WorkspaceRenameError::SymbolNotFound {
571 symbol: old_name.to_string(),
572 file: file_path.display().to_string(),
573 });
574 }
575
576 let file_edits: Vec<FileEdit> = edits_by_file
578 .into_iter()
579 .map(|(file_path, mut edits)| {
580 edits.sort_by_key(|e| std::cmp::Reverse(e.start));
581 FileEdit { file_path, edits }
582 })
583 .collect();
584
585 let total_changes: usize = file_edits.iter().map(|fe| fe.edits.len()).sum();
586 let files_modified = file_edits.len();
587
588 let backup_info =
590 if self.config.create_backups { self.create_backup(&file_edits).ok() } else { None };
591
592 let elapsed_ms = start.elapsed().as_millis() as u64;
593
594 if let Some(ref tx) = progress_tx {
596 let _ = tx.send(Progress::Complete { files_modified, changes: total_changes });
597 }
598
599 Ok(WorkspaceRenameResult {
600 file_edits,
601 backup_info,
602 description: format!("Rename '{}' to '{}'", old_name, new_name),
603 warnings: vec![],
604 statistics: RenameStatistics { files_modified, total_changes, elapsed_ms },
605 })
606 }
607
608 fn check_name_conflicts(
610 &self,
611 new_bare_name: &str,
612 scope_package: Option<&str>,
613 ) -> Result<(), WorkspaceRenameError> {
614 let all_symbols = self.index.all_symbols();
615
616 let mut conflicts = Vec::new();
617 for symbol in &all_symbols {
618 let matches_bare = symbol.name == new_bare_name;
619 let matches_qualified = if let Some(pkg) = scope_package {
620 let qualified = format!("{}::{}", pkg, new_bare_name);
621 symbol.qualified_name.as_deref() == Some(&qualified) || symbol.name == qualified
622 } else {
623 false
624 };
625
626 if matches_bare || matches_qualified {
627 conflicts.push(ConflictLocation {
628 file: perl_workspace::workspace_index::uri_to_fs_path(&symbol.uri)
629 .unwrap_or_default(),
630 line: symbol.range.start.line,
631 column: symbol.range.start.column,
632 existing_symbol: symbol
633 .qualified_name
634 .clone()
635 .unwrap_or_else(|| symbol.name.clone()),
636 });
637 }
638 }
639
640 if conflicts.is_empty() {
641 Ok(())
642 } else {
643 Err(WorkspaceRenameError::NameConflict {
644 new_name: new_bare_name.to_string(),
645 conflicts,
646 })
647 }
648 }
649
650 fn create_backup(&self, file_edits: &[FileEdit]) -> Result<BackupInfo, WorkspaceRenameError> {
652 let ts =
654 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
655 let backup_dir = std::env::temp_dir().join(format!(
656 "perl_rename_backup_{}_{}_{:?}",
657 ts.as_secs(),
658 ts.subsec_nanos(),
659 std::thread::current().id()
660 ));
661
662 std::fs::create_dir_all(&backup_dir).map_err(|e| {
663 WorkspaceRenameError::FileSystemError {
664 operation: "create_backup_dir".to_string(),
665 file: backup_dir.clone(),
666 error: e.to_string(),
667 }
668 })?;
669
670 let mut file_mappings = HashMap::new();
671
672 for (idx, file_edit) in file_edits.iter().enumerate() {
673 if file_edit.file_path.exists() {
674 let file_name = file_edit
676 .file_path
677 .file_name()
678 .unwrap_or_default()
679 .to_string_lossy()
680 .to_string();
681 let backup_name = format!("{}_{}", idx, file_name);
682 let backup_path = backup_dir.join(&backup_name);
683
684 std::fs::copy(&file_edit.file_path, &backup_path).map_err(|e| {
685 WorkspaceRenameError::FileSystemError {
686 operation: "backup_copy".to_string(),
687 file: file_edit.file_path.clone(),
688 error: e.to_string(),
689 }
690 })?;
691
692 file_mappings.insert(file_edit.file_path.clone(), backup_path);
693 }
694 }
695
696 Ok(BackupInfo { backup_dir, file_mappings })
697 }
698
699 pub fn apply_edits(&self, result: &WorkspaceRenameResult) -> Result<(), WorkspaceRenameError> {
703 let mut written_files = Vec::new();
704
705 for file_edit in &result.file_edits {
706 let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
708 if let Some(ref backup) = result.backup_info {
710 let _ = self.rollback_from_backup(&written_files, backup);
711 }
712 WorkspaceRenameError::FileSystemError {
713 operation: "read".to_string(),
714 file: file_edit.file_path.clone(),
715 error: e.to_string(),
716 }
717 })?;
718
719 let mut new_content = content;
721 for edit in &file_edit.edits {
722 if edit.start <= new_content.len() && edit.end <= new_content.len() {
723 new_content = format!(
724 "{}{}{}",
725 &new_content[..edit.start],
726 edit.new_text,
727 &new_content[edit.end..],
728 );
729 }
730 }
731
732 std::fs::write(&file_edit.file_path, &new_content).map_err(|e| {
734 if let Some(ref backup) = result.backup_info {
736 let _ = self.rollback_from_backup(&written_files, backup);
737 }
738 WorkspaceRenameError::FileSystemError {
739 operation: "write".to_string(),
740 file: file_edit.file_path.clone(),
741 error: e.to_string(),
742 }
743 })?;
744
745 written_files.push(file_edit.file_path.clone());
746 }
747
748 Ok(())
749 }
750
751 fn rollback_from_backup(
753 &self,
754 files: &[PathBuf],
755 backup: &BackupInfo,
756 ) -> Result<(), WorkspaceRenameError> {
757 for file in files {
758 if let Some(backup_path) = backup.file_mappings.get(file) {
759 std::fs::copy(backup_path, file).map_err(|e| {
760 WorkspaceRenameError::RollbackFailed {
761 original_error: "file write failed".to_string(),
762 rollback_error: format!("failed to restore {}: {}", file.display(), e),
763 backup_dir: backup.backup_dir.clone(),
764 }
765 })?;
766 }
767 }
768 Ok(())
769 }
770
771 pub fn update_index_after_rename(
775 &self,
776 old_name: &str,
777 new_name: &str,
778 file_edits: &[FileEdit],
779 ) -> Result<(), WorkspaceRenameError> {
780 for file_edit in file_edits {
782 let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
783 WorkspaceRenameError::IndexUpdateFailed {
784 error: format!("Failed to read {}: {}", file_edit.file_path.display(), e),
785 affected_files: vec![file_edit.file_path.clone()],
786 }
787 })?;
788
789 let uri_str = perl_workspace::workspace_index::fs_path_to_uri(&file_edit.file_path)
790 .map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
791 error: format!("URI conversion failed: {}", e),
792 affected_files: vec![file_edit.file_path.clone()],
793 })?;
794
795 self.index.remove_file(&uri_str);
797
798 let url =
799 url::Url::parse(&uri_str).map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
800 error: format!("URL parse failed: {}", e),
801 affected_files: vec![file_edit.file_path.clone()],
802 })?;
803
804 self.index.index_file(url, content).map_err(|e| {
805 WorkspaceRenameError::IndexUpdateFailed {
806 error: format!(
807 "Re-indexing failed for '{}' -> '{}': {}",
808 old_name, new_name, e
809 ),
810 affected_files: vec![file_edit.file_path.clone()],
811 }
812 })?;
813 }
814
815 Ok(())
816 }
817}
818
819#[derive(Clone, Copy, PartialEq, Eq, Debug)]
821enum StringContext {
822 Code,
824 SingleQuoted,
826 DoubleQuoted,
828 LineComment,
830}
831
832fn scan_string_context(text: &str, offset: usize) -> StringContext {
834 let mut state = StringContext::Code;
835 let mut escaped = false;
836
837 for (idx, byte) in text.bytes().enumerate() {
838 if idx >= offset {
839 return state;
840 }
841
842 match state {
843 StringContext::Code => match byte {
844 b'\'' => state = StringContext::SingleQuoted,
845 b'"' => state = StringContext::DoubleQuoted,
846 b'#' => state = StringContext::LineComment,
847 _ => {}
848 },
849 StringContext::SingleQuoted => {
850 if escaped {
851 escaped = false;
852 } else if byte == b'\\' {
853 escaped = true;
854 } else if byte == b'\'' {
855 state = StringContext::Code;
856 }
857 }
858 StringContext::DoubleQuoted => {
859 if escaped {
860 escaped = false;
861 } else if byte == b'\\' {
862 escaped = true;
863 } else if byte == b'"' {
864 state = StringContext::Code;
865 }
866 }
867 StringContext::LineComment => {
868 if byte == b'\n' {
869 state = StringContext::Code;
870 }
871 }
872 }
873 }
874
875 state
876}
877
878fn is_rename_code_position(text: &str, offset: usize) -> bool {
880 scan_string_context(text, offset) == StringContext::Code
881}
882
883fn is_interpolated_in_double_quote(text: &str, offset: usize) -> bool {
888 if scan_string_context(text, offset) != StringContext::DoubleQuoted {
889 return false;
890 }
891
892 if offset == 0 {
893 return false;
894 }
895
896 let before = &text[..offset];
897 let last_char = match before.chars().next_back() {
898 Some(c) => c,
899 None => return false,
900 };
901
902 if matches!(last_char, '$' | '@' | '%') {
903 return true;
904 }
905
906 if last_char == '{' {
907 let before_brace = &before[..before.len() - 1];
908 let sigil = before_brace.chars().next_back();
909 return matches!(sigil, Some('$') | Some('@') | Some('%'));
910 }
911
912 false
913}
914
915fn is_perl_ident_char(c: char) -> bool {
918 c.is_alphanumeric() || c == '_'
919}
920
921fn is_word_boundary_before(text: &str, byte_offset: usize) -> bool {
927 if byte_offset == 0 {
928 return true;
929 }
930 text[..byte_offset].chars().next_back().is_none_or(|c| !is_perl_ident_char(c))
931}
932
933fn is_word_boundary_after(text: &str, byte_offset: usize) -> bool {
939 if byte_offset >= text.len() {
940 return true;
941 }
942 text[byte_offset..].chars().next().is_none_or(|c| !is_perl_ident_char(c))
943}
944
945fn find_package_at_offset(text: &str, offset: usize) -> Option<String> {
947 let before = &text[..offset];
948 let mut last_package = None;
950 let mut search_pos = 0;
951 while let Some(found) = before[search_pos..].find("package ") {
952 let pkg_start = search_pos + found + "package ".len();
953 let remaining = &before[pkg_start..];
955 let pkg_end = remaining
956 .find(|c: char| c == ';' || c == '{' || c.is_whitespace())
957 .unwrap_or(remaining.len());
958 let pkg_name = remaining[..pkg_end].trim();
959 if !pkg_name.is_empty() {
960 last_package = Some(pkg_name.to_string());
961 }
962 search_pos = pkg_start;
963 }
964 last_package
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn test_config_defaults() {
973 let config = WorkspaceRenameConfig::default();
974 assert!(config.atomic_mode);
975 assert!(config.create_backups);
976 assert_eq!(config.operation_timeout, 60);
977 assert!(config.parallel_processing);
978 assert_eq!(config.batch_size, 10);
979 assert_eq!(config.max_files, 0);
980 assert!(config.report_progress);
981 assert!(config.validate_syntax);
982 }
983
984 #[test]
985 fn test_split_qualified_name() {
986 assert_eq!(split_qualified_name("process"), (None, "process"));
987 assert_eq!(split_qualified_name("Utils::process"), (Some("Utils"), "process"));
988 assert_eq!(split_qualified_name("A::B::process"), (Some("A::B"), "process"));
989 }
990
991 #[test]
992 fn test_is_perl_ident_char() {
993 assert!(is_perl_ident_char('a'));
994 assert!(is_perl_ident_char('Z'));
995 assert!(is_perl_ident_char('0'));
996 assert!(is_perl_ident_char('_'));
997 assert!(!is_perl_ident_char(' '));
998 assert!(!is_perl_ident_char(':'));
999 assert!(!is_perl_ident_char(';'));
1000 assert!(is_perl_ident_char('α'));
1002 assert!(is_perl_ident_char('変'));
1003 assert!(!is_perl_ident_char('\u{B0}')); }
1006
1007 #[test]
1008 fn test_find_package_at_offset() {
1009 let text = "package Foo;\nsub bar { 1 }\npackage Bar;\nsub baz { 2 }\n";
1010 assert_eq!(find_package_at_offset(text, 20), Some("Foo".to_string()));
1011 assert_eq!(find_package_at_offset(text, 45), Some("Bar".to_string()));
1012 assert_eq!(find_package_at_offset(text, 0), None);
1013 }
1014
1015 #[test]
1020 fn test_is_word_boundary_before_ascii() {
1021 assert!(is_word_boundary_before("foo", 0));
1023 assert!(is_word_boundary_before("x foo", 2));
1025 assert!(!is_word_boundary_before("xfoo", 1));
1027 assert!(!is_word_boundary_before("_foo", 1));
1028 }
1029
1030 #[test]
1031 fn test_is_word_boundary_before_unicode() {
1032 let text = "変数foo";
1034 let foo_start = text.find("foo").unwrap();
1035 assert!(!is_word_boundary_before(text, foo_start));
1037
1038 let text2 = "αfoo";
1040 let foo_start2 = text2.find("foo").unwrap();
1041 assert!(!is_word_boundary_before(text2, foo_start2));
1042
1043 assert!(is_word_boundary_before("$foo", 1));
1045 }
1046
1047 #[test]
1048 fn test_is_word_boundary_after_ascii() {
1049 let text = "foo bar";
1050 assert!(is_word_boundary_after(text, text.len()));
1052 assert!(is_word_boundary_after(text, 3));
1054 assert!(!is_word_boundary_after(text, 1));
1056 }
1057
1058 #[test]
1059 fn test_is_word_boundary_after_unicode() {
1060 let text = "fooα";
1062 assert!(!is_word_boundary_after(text, 3));
1064
1065 let text2 = "foo変";
1067 assert!(!is_word_boundary_after(text2, 3));
1068
1069 assert!(is_word_boundary_after("foo ", 3));
1071 }
1072
1073 #[test]
1078 fn test_scan_string_context_code_positions() {
1079 let text = "sub foo { foo(); }\n";
1080 let foo_pos = text.find("foo").unwrap_or(0);
1081 assert_eq!(scan_string_context(text, foo_pos), StringContext::Code);
1082 assert_eq!(scan_string_context(text, text.len()), StringContext::Code);
1083 }
1084
1085 #[test]
1086 fn test_scan_string_context_single_quoted() {
1087 let text = "my $x = 'hello';";
1088 let h_pos = text.find("hello").unwrap_or(0);
1089 assert_eq!(scan_string_context(text, h_pos), StringContext::SingleQuoted);
1090 }
1091
1092 #[test]
1093 fn test_scan_string_context_double_quoted() {
1094 let text = "my $x = \"hello\";";
1095 let h_pos = text.find("hello").unwrap_or(0);
1096 assert_eq!(scan_string_context(text, h_pos), StringContext::DoubleQuoted);
1097 }
1098
1099 #[test]
1100 fn test_scan_string_context_line_comment() {
1101 let text = "foo(); # bar in comment\n";
1102 let bar_pos = text.find("bar").unwrap_or(0);
1103 assert_eq!(scan_string_context(text, bar_pos), StringContext::LineComment);
1104 }
1105
1106 #[test]
1107 fn test_is_interpolated_direct_sigils() {
1108 let dollar = "\"$var\"";
1109 let v_pos = dollar.find("var").unwrap_or(0);
1110 assert!(is_interpolated_in_double_quote(dollar, v_pos), "$var must be interpolated");
1111
1112 let at = "\"@arr\"";
1113 let a_pos = at.find("arr").unwrap_or(0);
1114 assert!(is_interpolated_in_double_quote(at, a_pos), "@arr must be interpolated");
1115
1116 let percent = "\"%hash\"";
1117 let h_pos = percent.find("hash").unwrap_or(0);
1118 assert!(is_interpolated_in_double_quote(percent, h_pos), "%hash must be interpolated");
1119 }
1120
1121 #[test]
1122 fn test_is_interpolated_braced_sigils() {
1123 let braced_dollar = "\"${var}\"";
1124 let v_pos = braced_dollar.find("var").unwrap_or(0);
1125 assert!(
1126 is_interpolated_in_double_quote(braced_dollar, v_pos),
1127 "${{var}} must be interpolated"
1128 );
1129
1130 let braced_at = "\"@{arr}\"";
1131 let a_pos = braced_at.find("arr").unwrap_or(0);
1132 assert!(is_interpolated_in_double_quote(braced_at, a_pos), "@{{arr}} must be interpolated");
1133 }
1134
1135 #[test]
1136 fn test_is_interpolated_bare_text_in_string_not_interpolated() {
1137 let text = "\"hello var text\"";
1138 let var_pos = text.find("var").unwrap_or(0);
1139 assert!(
1140 !is_interpolated_in_double_quote(text, var_pos),
1141 "bare text in string must NOT be treated as interpolated"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_is_interpolated_not_in_string_returns_false() {
1147 let text = "my $foo = 1;";
1148 let foo_pos = text.find("foo").unwrap_or(0);
1149 assert!(
1150 !is_interpolated_in_double_quote(text, foo_pos),
1151 "code position must NOT be treated as interpolated"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_is_interpolated_single_quoted_returns_false() {
1157 let text = "my $x = '$foo';";
1158 let foo_pos = text.find("foo").unwrap_or(0);
1159 assert!(
1160 !is_interpolated_in_double_quote(text, foo_pos),
1161 "single-quoted string must never be treated as interpolated"
1162 );
1163 }
1164}