use super::refactoring::BackupInfo;
use super::workspace_refactor::{FileEdit, TextEdit};
use perl_parser_core::qualified_name::split_qualified_name;
use perl_workspace::workspace_index::WorkspaceIndex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceRenameConfig {
pub atomic_mode: bool,
pub create_backups: bool,
pub operation_timeout: u64,
pub parallel_processing: bool,
pub batch_size: usize,
pub max_files: usize,
pub report_progress: bool,
pub validate_syntax: bool,
}
impl Default for WorkspaceRenameConfig {
fn default() -> Self {
Self {
atomic_mode: true,
create_backups: true,
operation_timeout: 60,
parallel_processing: true,
batch_size: 10,
max_files: 0,
report_progress: true,
validate_syntax: true,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkspaceRenameResult {
pub file_edits: Vec<FileEdit>,
pub backup_info: Option<BackupInfo>,
pub description: String,
pub warnings: Vec<String>,
pub statistics: RenameStatistics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameStatistics {
pub files_modified: usize,
pub total_changes: usize,
pub elapsed_ms: u64,
}
#[derive(Debug, Clone)]
pub enum Progress {
Scanning {
total: usize,
},
Processing {
current: usize,
total: usize,
file: PathBuf,
},
Complete {
files_modified: usize,
changes: usize,
},
}
#[derive(Debug, Clone)]
pub enum WorkspaceRenameError {
SymbolNotFound {
symbol: String,
file: String,
},
NameConflict {
new_name: String,
conflicts: Vec<ConflictLocation>,
},
Timeout {
elapsed_seconds: u64,
files_processed: usize,
total_files: usize,
},
FileSystemError {
operation: String,
file: PathBuf,
error: String,
},
RollbackFailed {
original_error: String,
rollback_error: String,
backup_dir: PathBuf,
},
IndexUpdateFailed {
error: String,
affected_files: Vec<PathBuf>,
},
SecurityError {
message: String,
path: Option<PathBuf>,
},
NotImplemented {
feature: String,
},
}
impl std::fmt::Display for WorkspaceRenameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WorkspaceRenameError::SymbolNotFound { symbol, file } => {
write!(f, "Symbol '{}' not found in {}", symbol, file)
}
WorkspaceRenameError::NameConflict { new_name, conflicts } => {
write!(f, "Name '{}' conflicts with {} existing symbols", new_name, conflicts.len())
}
WorkspaceRenameError::Timeout { elapsed_seconds, files_processed, total_files } => {
write!(
f,
"Operation timed out after {}s ({}/{} files)",
elapsed_seconds, files_processed, total_files
)
}
WorkspaceRenameError::FileSystemError { operation, file, error } => {
write!(f, "File system error during {}: {} - {}", operation, file.display(), error)
}
WorkspaceRenameError::RollbackFailed { original_error, rollback_error, backup_dir } => {
write!(
f,
"Rollback failed - original: {}, rollback: {}, backup: {}",
original_error,
rollback_error,
backup_dir.display()
)
}
WorkspaceRenameError::IndexUpdateFailed { error, affected_files } => {
write!(f, "Index update failed: {} ({} files)", error, affected_files.len())
}
WorkspaceRenameError::SecurityError { message, path } => {
if let Some(p) = path {
write!(f, "Security error: {} ({})", message, p.display())
} else {
write!(f, "Security error: {}", message)
}
}
WorkspaceRenameError::NotImplemented { feature } => {
write!(f, "Feature not yet implemented: {}", feature)
}
}
}
}
impl std::error::Error for WorkspaceRenameError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictLocation {
pub file: PathBuf,
pub line: u32,
pub column: u32,
pub existing_symbol: String,
}
pub struct WorkspaceRename {
index: WorkspaceIndex,
config: WorkspaceRenameConfig,
}
impl WorkspaceRename {
pub fn new(index: WorkspaceIndex, config: WorkspaceRenameConfig) -> Self {
Self { index, config }
}
pub fn index(&self) -> &WorkspaceIndex {
&self.index
}
pub fn rename_symbol(
&self,
old_name: &str,
new_name: &str,
file_path: &Path,
_position: (usize, usize),
) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
self.rename_symbol_impl(old_name, new_name, file_path, None)
}
pub fn rename_symbol_with_progress(
&self,
old_name: &str,
new_name: &str,
file_path: &Path,
_position: (usize, usize),
progress_tx: std::sync::mpsc::Sender<Progress>,
) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
self.rename_symbol_impl(old_name, new_name, file_path, Some(progress_tx))
}
fn rename_symbol_impl(
&self,
old_name: &str,
new_name: &str,
file_path: &Path,
progress_tx: Option<std::sync::mpsc::Sender<Progress>>,
) -> Result<WorkspaceRenameResult, WorkspaceRenameError> {
let start = Instant::now();
let timeout = std::time::Duration::from_secs(self.config.operation_timeout);
let (old_package, old_bare) = split_qualified_name(old_name);
let (_new_package, new_bare) = split_qualified_name(new_name);
self.check_name_conflicts(new_bare, old_package)?;
let definition = self.index.find_definition(old_name);
let scope_package = old_package.map(|p| p.to_string());
let mut all_references = self.index.find_references(old_name);
if let Some(_pkg) = &scope_package {
let qualified = format!("{}::{}", _pkg, old_bare);
let qualified_refs = self.index.find_references(&qualified);
for r in qualified_refs {
if !all_references
.iter()
.any(|existing| existing.uri == r.uri && existing.range == r.range)
{
all_references.push(r);
}
}
let bare_refs = self.index.find_references(old_bare);
for r in bare_refs {
if !all_references
.iter()
.any(|existing| existing.uri == r.uri && existing.range == r.range)
{
all_references.push(r);
}
}
}
if let Some(ref def) = definition {
if !all_references.iter().any(|r| r.uri == def.uri && r.range == def.range) {
all_references.push(def.clone());
}
}
let store = self.index.document_store();
let all_docs = store.all_documents();
let total_files = all_docs.len();
if let Some(ref tx) = progress_tx {
let _ = tx.send(Progress::Scanning { total: total_files });
}
let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
let mut files_processed = 0;
for (idx, doc) in all_docs.iter().enumerate() {
if start.elapsed() > timeout {
return Err(WorkspaceRenameError::Timeout {
elapsed_seconds: start.elapsed().as_secs(),
files_processed,
total_files,
});
}
if self.config.max_files > 0 && files_processed >= self.config.max_files {
break;
}
let doc_path = perl_workspace::workspace_index::uri_to_fs_path(&doc.uri);
if let Some(ref tx) = progress_tx {
let _ = tx.send(Progress::Processing {
current: idx + 1,
total: total_files,
file: doc_path.clone().unwrap_or_default(),
});
}
let text = &doc.text;
if !text.contains(old_bare) {
files_processed += 1;
continue;
}
let line_index = &doc.line_index;
let mut search_pos = 0;
let mut file_edits = Vec::new();
while let Some(found) = text[search_pos..].find(old_bare) {
let match_start = search_pos + found;
let match_end = match_start + old_bare.len();
if match_end > text.len() {
break;
}
let is_word_start = is_word_boundary_before(text, match_start);
let is_word_end = is_word_boundary_after(text, match_end);
if is_word_start
&& is_word_end
&& (is_rename_code_position(text, match_start)
|| is_interpolated_in_double_quote(text, match_start))
{
let in_scope = if let Some(ref pkg) = scope_package {
let before = &text[..match_start];
let is_qualified_with_pkg = before.ends_with(&format!("{}::", pkg));
let current_package = find_package_at_offset(text, match_start);
let in_package_scope = current_package.as_deref() == Some(pkg.as_str());
is_qualified_with_pkg || in_package_scope
} else {
true
};
if in_scope {
let (edit_start, replacement) = if let Some(ref pkg) = scope_package {
let prefix = format!("{}::", pkg);
if match_start >= prefix.len()
&& text[match_start - prefix.len()..match_start] == *prefix
{
(match_start - prefix.len(), format!("{}::{}", pkg, new_bare))
} else {
(match_start, new_bare.to_string())
}
} else {
(match_start, new_bare.to_string())
};
let (start_line, start_col) = line_index.offset_to_position(edit_start);
let (end_line, end_col) = line_index.offset_to_position(match_end);
if let (Some(start_byte), Some(end_byte)) = (
line_index.position_to_offset(start_line, start_col),
line_index.position_to_offset(end_line, end_col),
) {
file_edits.push(TextEdit {
start: start_byte,
end: end_byte,
new_text: replacement,
});
}
}
}
search_pos = match_end;
if file_edits.len() >= 1000 {
break;
}
}
if !file_edits.is_empty() {
if let Some(path) = doc_path {
edits_by_file.entry(path).or_default().extend(file_edits);
}
}
files_processed += 1;
}
if edits_by_file.is_empty() {
return Err(WorkspaceRenameError::SymbolNotFound {
symbol: old_name.to_string(),
file: file_path.display().to_string(),
});
}
let file_edits: Vec<FileEdit> = edits_by_file
.into_iter()
.map(|(file_path, mut edits)| {
edits.sort_by_key(|e| std::cmp::Reverse(e.start));
FileEdit { file_path, edits }
})
.collect();
let total_changes: usize = file_edits.iter().map(|fe| fe.edits.len()).sum();
let files_modified = file_edits.len();
let backup_info =
if self.config.create_backups { self.create_backup(&file_edits).ok() } else { None };
let elapsed_ms = start.elapsed().as_millis() as u64;
if let Some(ref tx) = progress_tx {
let _ = tx.send(Progress::Complete { files_modified, changes: total_changes });
}
Ok(WorkspaceRenameResult {
file_edits,
backup_info,
description: format!("Rename '{}' to '{}'", old_name, new_name),
warnings: vec![],
statistics: RenameStatistics { files_modified, total_changes, elapsed_ms },
})
}
fn check_name_conflicts(
&self,
new_bare_name: &str,
scope_package: Option<&str>,
) -> Result<(), WorkspaceRenameError> {
let all_symbols = self.index.all_symbols();
let mut conflicts = Vec::new();
for symbol in &all_symbols {
let matches_bare = symbol.name == new_bare_name;
let matches_qualified = if let Some(pkg) = scope_package {
let qualified = format!("{}::{}", pkg, new_bare_name);
symbol.qualified_name.as_deref() == Some(&qualified) || symbol.name == qualified
} else {
false
};
if matches_bare || matches_qualified {
conflicts.push(ConflictLocation {
file: perl_workspace::workspace_index::uri_to_fs_path(&symbol.uri)
.unwrap_or_default(),
line: symbol.range.start.line,
column: symbol.range.start.column,
existing_symbol: symbol
.qualified_name
.clone()
.unwrap_or_else(|| symbol.name.clone()),
});
}
}
if conflicts.is_empty() {
Ok(())
} else {
Err(WorkspaceRenameError::NameConflict {
new_name: new_bare_name.to_string(),
conflicts,
})
}
}
fn create_backup(&self, file_edits: &[FileEdit]) -> Result<BackupInfo, WorkspaceRenameError> {
let ts =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let backup_dir = std::env::temp_dir().join(format!(
"perl_rename_backup_{}_{}_{:?}",
ts.as_secs(),
ts.subsec_nanos(),
std::thread::current().id()
));
std::fs::create_dir_all(&backup_dir).map_err(|e| {
WorkspaceRenameError::FileSystemError {
operation: "create_backup_dir".to_string(),
file: backup_dir.clone(),
error: e.to_string(),
}
})?;
let mut file_mappings = HashMap::new();
for (idx, file_edit) in file_edits.iter().enumerate() {
if file_edit.file_path.exists() {
let file_name = file_edit
.file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let backup_name = format!("{}_{}", idx, file_name);
let backup_path = backup_dir.join(&backup_name);
std::fs::copy(&file_edit.file_path, &backup_path).map_err(|e| {
WorkspaceRenameError::FileSystemError {
operation: "backup_copy".to_string(),
file: file_edit.file_path.clone(),
error: e.to_string(),
}
})?;
file_mappings.insert(file_edit.file_path.clone(), backup_path);
}
}
Ok(BackupInfo { backup_dir, file_mappings })
}
pub fn apply_edits(&self, result: &WorkspaceRenameResult) -> Result<(), WorkspaceRenameError> {
let mut written_files = Vec::new();
for file_edit in &result.file_edits {
let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
if let Some(ref backup) = result.backup_info {
let _ = self.rollback_from_backup(&written_files, backup);
}
WorkspaceRenameError::FileSystemError {
operation: "read".to_string(),
file: file_edit.file_path.clone(),
error: e.to_string(),
}
})?;
let mut new_content = content;
for edit in &file_edit.edits {
if edit.start <= new_content.len() && edit.end <= new_content.len() {
new_content = format!(
"{}{}{}",
&new_content[..edit.start],
edit.new_text,
&new_content[edit.end..],
);
}
}
std::fs::write(&file_edit.file_path, &new_content).map_err(|e| {
if let Some(ref backup) = result.backup_info {
let _ = self.rollback_from_backup(&written_files, backup);
}
WorkspaceRenameError::FileSystemError {
operation: "write".to_string(),
file: file_edit.file_path.clone(),
error: e.to_string(),
}
})?;
written_files.push(file_edit.file_path.clone());
}
Ok(())
}
fn rollback_from_backup(
&self,
files: &[PathBuf],
backup: &BackupInfo,
) -> Result<(), WorkspaceRenameError> {
for file in files {
if let Some(backup_path) = backup.file_mappings.get(file) {
std::fs::copy(backup_path, file).map_err(|e| {
WorkspaceRenameError::RollbackFailed {
original_error: "file write failed".to_string(),
rollback_error: format!("failed to restore {}: {}", file.display(), e),
backup_dir: backup.backup_dir.clone(),
}
})?;
}
}
Ok(())
}
pub fn update_index_after_rename(
&self,
old_name: &str,
new_name: &str,
file_edits: &[FileEdit],
) -> Result<(), WorkspaceRenameError> {
for file_edit in file_edits {
let content = std::fs::read_to_string(&file_edit.file_path).map_err(|e| {
WorkspaceRenameError::IndexUpdateFailed {
error: format!("Failed to read {}: {}", file_edit.file_path.display(), e),
affected_files: vec![file_edit.file_path.clone()],
}
})?;
let uri_str = perl_workspace::workspace_index::fs_path_to_uri(&file_edit.file_path)
.map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
error: format!("URI conversion failed: {}", e),
affected_files: vec![file_edit.file_path.clone()],
})?;
self.index.remove_file(&uri_str);
let url =
url::Url::parse(&uri_str).map_err(|e| WorkspaceRenameError::IndexUpdateFailed {
error: format!("URL parse failed: {}", e),
affected_files: vec![file_edit.file_path.clone()],
})?;
self.index.index_file(url, content).map_err(|e| {
WorkspaceRenameError::IndexUpdateFailed {
error: format!(
"Re-indexing failed for '{}' -> '{}': {}",
old_name, new_name, e
),
affected_files: vec![file_edit.file_path.clone()],
}
})?;
}
Ok(())
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum StringContext {
Code,
SingleQuoted,
DoubleQuoted,
LineComment,
}
fn scan_string_context(text: &str, offset: usize) -> StringContext {
let mut state = StringContext::Code;
let mut escaped = false;
for (idx, byte) in text.bytes().enumerate() {
if idx >= offset {
return state;
}
match state {
StringContext::Code => match byte {
b'\'' => state = StringContext::SingleQuoted,
b'"' => state = StringContext::DoubleQuoted,
b'#' => state = StringContext::LineComment,
_ => {}
},
StringContext::SingleQuoted => {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'\'' {
state = StringContext::Code;
}
}
StringContext::DoubleQuoted => {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
state = StringContext::Code;
}
}
StringContext::LineComment => {
if byte == b'\n' {
state = StringContext::Code;
}
}
}
}
state
}
fn is_rename_code_position(text: &str, offset: usize) -> bool {
scan_string_context(text, offset) == StringContext::Code
}
fn is_interpolated_in_double_quote(text: &str, offset: usize) -> bool {
if scan_string_context(text, offset) != StringContext::DoubleQuoted {
return false;
}
if offset == 0 {
return false;
}
let before = &text[..offset];
let last_char = match before.chars().next_back() {
Some(c) => c,
None => return false,
};
if matches!(last_char, '$' | '@' | '%') {
return true;
}
if last_char == '{' {
let before_brace = &before[..before.len() - 1];
let sigil = before_brace.chars().next_back();
return matches!(sigil, Some('$') | Some('@') | Some('%'));
}
false
}
fn is_perl_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_word_boundary_before(text: &str, byte_offset: usize) -> bool {
if byte_offset == 0 {
return true;
}
text[..byte_offset].chars().next_back().is_none_or(|c| !is_perl_ident_char(c))
}
fn is_word_boundary_after(text: &str, byte_offset: usize) -> bool {
if byte_offset >= text.len() {
return true;
}
text[byte_offset..].chars().next().is_none_or(|c| !is_perl_ident_char(c))
}
fn find_package_at_offset(text: &str, offset: usize) -> Option<String> {
let before = &text[..offset];
let mut last_package = None;
let mut search_pos = 0;
while let Some(found) = before[search_pos..].find("package ") {
let pkg_start = search_pos + found + "package ".len();
let remaining = &before[pkg_start..];
let pkg_end = remaining
.find(|c: char| c == ';' || c == '{' || c.is_whitespace())
.unwrap_or(remaining.len());
let pkg_name = remaining[..pkg_end].trim();
if !pkg_name.is_empty() {
last_package = Some(pkg_name.to_string());
}
search_pos = pkg_start;
}
last_package
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_defaults() {
let config = WorkspaceRenameConfig::default();
assert!(config.atomic_mode);
assert!(config.create_backups);
assert_eq!(config.operation_timeout, 60);
assert!(config.parallel_processing);
assert_eq!(config.batch_size, 10);
assert_eq!(config.max_files, 0);
assert!(config.report_progress);
assert!(config.validate_syntax);
}
#[test]
fn test_split_qualified_name() {
assert_eq!(split_qualified_name("process"), (None, "process"));
assert_eq!(split_qualified_name("Utils::process"), (Some("Utils"), "process"));
assert_eq!(split_qualified_name("A::B::process"), (Some("A::B"), "process"));
}
#[test]
fn test_is_perl_ident_char() {
assert!(is_perl_ident_char('a'));
assert!(is_perl_ident_char('Z'));
assert!(is_perl_ident_char('0'));
assert!(is_perl_ident_char('_'));
assert!(!is_perl_ident_char(' '));
assert!(!is_perl_ident_char(':'));
assert!(!is_perl_ident_char(';'));
assert!(is_perl_ident_char('α'));
assert!(is_perl_ident_char('変'));
assert!(!is_perl_ident_char('\u{B0}')); }
#[test]
fn test_find_package_at_offset() {
let text = "package Foo;\nsub bar { 1 }\npackage Bar;\nsub baz { 2 }\n";
assert_eq!(find_package_at_offset(text, 20), Some("Foo".to_string()));
assert_eq!(find_package_at_offset(text, 45), Some("Bar".to_string()));
assert_eq!(find_package_at_offset(text, 0), None);
}
#[test]
fn test_is_word_boundary_before_ascii() {
assert!(is_word_boundary_before("foo", 0));
assert!(is_word_boundary_before("x foo", 2));
assert!(!is_word_boundary_before("xfoo", 1));
assert!(!is_word_boundary_before("_foo", 1));
}
#[test]
fn test_is_word_boundary_before_unicode() {
let text = "変数foo";
let foo_start = text.find("foo").unwrap();
assert!(!is_word_boundary_before(text, foo_start));
let text2 = "αfoo";
let foo_start2 = text2.find("foo").unwrap();
assert!(!is_word_boundary_before(text2, foo_start2));
assert!(is_word_boundary_before("$foo", 1));
}
#[test]
fn test_is_word_boundary_after_ascii() {
let text = "foo bar";
assert!(is_word_boundary_after(text, text.len()));
assert!(is_word_boundary_after(text, 3));
assert!(!is_word_boundary_after(text, 1));
}
#[test]
fn test_is_word_boundary_after_unicode() {
let text = "fooα";
assert!(!is_word_boundary_after(text, 3));
let text2 = "foo変";
assert!(!is_word_boundary_after(text2, 3));
assert!(is_word_boundary_after("foo ", 3));
}
#[test]
fn test_scan_string_context_code_positions() {
let text = "sub foo { foo(); }\n";
let foo_pos = text.find("foo").unwrap_or(0);
assert_eq!(scan_string_context(text, foo_pos), StringContext::Code);
assert_eq!(scan_string_context(text, text.len()), StringContext::Code);
}
#[test]
fn test_scan_string_context_single_quoted() {
let text = "my $x = 'hello';";
let h_pos = text.find("hello").unwrap_or(0);
assert_eq!(scan_string_context(text, h_pos), StringContext::SingleQuoted);
}
#[test]
fn test_scan_string_context_double_quoted() {
let text = "my $x = \"hello\";";
let h_pos = text.find("hello").unwrap_or(0);
assert_eq!(scan_string_context(text, h_pos), StringContext::DoubleQuoted);
}
#[test]
fn test_scan_string_context_line_comment() {
let text = "foo(); # bar in comment\n";
let bar_pos = text.find("bar").unwrap_or(0);
assert_eq!(scan_string_context(text, bar_pos), StringContext::LineComment);
}
#[test]
fn test_is_interpolated_direct_sigils() {
let dollar = "\"$var\"";
let v_pos = dollar.find("var").unwrap_or(0);
assert!(is_interpolated_in_double_quote(dollar, v_pos), "$var must be interpolated");
let at = "\"@arr\"";
let a_pos = at.find("arr").unwrap_or(0);
assert!(is_interpolated_in_double_quote(at, a_pos), "@arr must be interpolated");
let percent = "\"%hash\"";
let h_pos = percent.find("hash").unwrap_or(0);
assert!(is_interpolated_in_double_quote(percent, h_pos), "%hash must be interpolated");
}
#[test]
fn test_is_interpolated_braced_sigils() {
let braced_dollar = "\"${var}\"";
let v_pos = braced_dollar.find("var").unwrap_or(0);
assert!(
is_interpolated_in_double_quote(braced_dollar, v_pos),
"${{var}} must be interpolated"
);
let braced_at = "\"@{arr}\"";
let a_pos = braced_at.find("arr").unwrap_or(0);
assert!(is_interpolated_in_double_quote(braced_at, a_pos), "@{{arr}} must be interpolated");
}
#[test]
fn test_is_interpolated_bare_text_in_string_not_interpolated() {
let text = "\"hello var text\"";
let var_pos = text.find("var").unwrap_or(0);
assert!(
!is_interpolated_in_double_quote(text, var_pos),
"bare text in string must NOT be treated as interpolated"
);
}
#[test]
fn test_is_interpolated_not_in_string_returns_false() {
let text = "my $foo = 1;";
let foo_pos = text.find("foo").unwrap_or(0);
assert!(
!is_interpolated_in_double_quote(text, foo_pos),
"code position must NOT be treated as interpolated"
);
}
#[test]
fn test_is_interpolated_single_quoted_returns_false() {
let text = "my $x = '$foo';";
let foo_pos = text.find("foo").unwrap_or(0);
assert!(
!is_interpolated_in_double_quote(text, foo_pos),
"single-quoted string must never be treated as interpolated"
);
}
}