use crate::import_optimizer::ImportOptimizer;
use crate::workspace_index::{
SymKind, SymbolKey, WorkspaceIndex, fs_path_to_uri, normalize_var, uri_to_fs_path,
};
use perl_module_path::module_name_to_path;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
#[derive(Debug, Clone)]
pub enum RefactorError {
UriConversion(String),
DocumentNotIndexed(String),
InvalidPosition {
file: String,
details: String,
},
SymbolNotFound {
symbol: String,
file: String,
},
ParseError(String),
InvalidInput(String),
FileSystemError(String),
}
impl fmt::Display for RefactorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RefactorError::UriConversion(msg) => write!(f, "URI conversion failed: {}", msg),
RefactorError::DocumentNotIndexed(file) => {
write!(f, "Document not indexed in workspace: {}", file)
}
RefactorError::InvalidPosition { file, details } => {
write!(f, "Invalid position in {}: {}", file, details)
}
RefactorError::SymbolNotFound { symbol, file } => {
write!(f, "Symbol '{}' not found in {}", symbol, file)
}
RefactorError::ParseError(msg) => write!(f, "Parse error: {}", msg),
RefactorError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
RefactorError::FileSystemError(msg) => write!(f, "File system error: {}", msg),
}
}
}
impl std::error::Error for RefactorError {}
static IMPORT_BLOCK_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
fn get_import_block_regex() -> Option<&'static Regex> {
IMPORT_BLOCK_RE.get_or_init(|| Regex::new(r"(?m)^(?:use\s+[\w:]+[^\n]*\n)+")).as_ref().ok()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEdit {
pub file_path: PathBuf,
pub edits: Vec<TextEdit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextEdit {
pub start: usize,
pub end: usize,
pub new_text: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RefactorResult {
pub file_edits: Vec<FileEdit>,
pub description: String,
pub warnings: Vec<String>,
}
pub struct WorkspaceRefactor {
pub _index: WorkspaceIndex,
}
impl WorkspaceRefactor {
pub fn new(index: WorkspaceIndex) -> Self {
Self { _index: index }
}
pub fn rename_symbol(
&self,
old_name: &str,
new_name: &str,
_file_path: &Path,
_position: (usize, usize),
) -> Result<RefactorResult, RefactorError> {
if old_name.is_empty() {
return Err(RefactorError::InvalidInput("Symbol name cannot be empty".to_string()));
}
if new_name.is_empty() {
return Err(RefactorError::InvalidInput("New name cannot be empty".to_string()));
}
if old_name == new_name {
return Err(RefactorError::InvalidInput("Old and new names are identical".to_string()));
}
let (sigil, bare) = normalize_var(old_name);
let kind = if sigil.is_some() { SymKind::Var } else { SymKind::Sub };
let key = SymbolKey {
pkg: Arc::from("main".to_string()),
name: Arc::from(bare.to_string()),
sigil,
kind,
};
println!("rename_symbol DEBUG: search key={:?}", key);
println!(
"rename_symbol DEBUG: all symbols in index: {:?}",
self._index.all_symbols().iter().map(|s| &s.name).collect::<Vec<_>>()
);
let mut edits: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
let mut locations = self._index.find_refs(&key);
println!("rename_symbol DEBUG: find_refs result count: {}", locations.len());
let def_loc = self._index.find_def(&key);
println!("rename_symbol DEBUG: find_def result: {:?}", def_loc);
if let Some(def) = def_loc {
if !locations.iter().any(|loc| loc.uri == def.uri && loc.range == def.range) {
locations.push(def);
}
}
let store = self._index.document_store();
println!("rename_symbol DEBUG: store has {} documents", store.all_documents().len());
for doc in store.all_documents() {
println!("rename_symbol DEBUG: doc in store: {}", doc.uri);
}
if locations.is_empty() {
println!(
"rename_symbol DEBUG: locations empty, using fallback naive search for {}",
old_name
);
let _old_name_bytes = old_name.as_bytes();
for doc in store.all_documents() {
println!(
"rename_symbol DEBUG: naive search checking doc: {}, contains {}: {}",
doc.uri,
old_name,
doc.text.contains(old_name)
);
if !doc.text.contains(old_name) {
continue;
}
let idx = doc.line_index.clone();
let mut pos = 0;
let _text_bytes = doc.text.as_bytes();
while let Some(found) = doc.text[pos..].find(old_name) {
let start = pos + found;
let end = start + old_name.len();
if start >= doc.text.len() || end > doc.text.len() {
break;
}
let (start_line, start_col) = idx.offset_to_position(start);
let (end_line, end_col) = idx.offset_to_position(end);
let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
locations.push(crate::workspace_index::Location {
uri: doc.uri.clone(),
range: crate::position::Range {
start: crate::position::Position {
byte: start_byte,
line: start_line,
column: start_col,
},
end: crate::position::Position {
byte: end_byte,
line: end_line,
column: end_col,
},
},
});
pos = end;
if locations.len() >= 1000 {
break;
}
}
if locations.len() >= 500 {
break;
}
}
}
for loc in locations {
println!(
"rename_symbol DEBUG: processing location: {} at {}:{}",
loc.uri, loc.range.start.line, loc.range.start.column
);
let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
})?;
if let Some(doc) = store.get(&loc.uri) {
let start_off =
doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
let end_off =
doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
println!(
"rename_symbol DEBUG: offset for {}:{}: start={:?}, end={:?}",
loc.range.start.line, loc.range.start.column, start_off, end_off
);
if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
let replacement = match kind {
SymKind::Var => {
let sig = sigil.unwrap_or('$');
format!("{}{}", sig, new_name.trim_start_matches(['$', '@', '%']))
}
_ => new_name.to_string(),
};
println!(
"rename_symbol DEBUG: replacement for {} is {}",
old_name, replacement
);
edits.entry(path).or_default().push(TextEdit {
start: start_off,
end: end_off,
new_text: replacement,
});
}
}
}
let file_edits: Vec<FileEdit> =
edits.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
let description = format!("Rename '{}' to '{}'", old_name, new_name);
println!(
"rename_symbol DEBUG: returning RefactorResult with {} file_edits, description: {}",
file_edits.len(),
description
);
Ok(RefactorResult { file_edits, description, warnings: vec![] })
}
pub fn extract_module(
&self,
file_path: &Path,
start_line: usize,
end_line: usize,
module_name: &str,
) -> Result<RefactorResult, RefactorError> {
if module_name.is_empty() {
return Err(RefactorError::InvalidInput("Module name cannot be empty".to_string()));
}
if start_line > end_line {
return Err(RefactorError::InvalidInput(
"Start line cannot be after end line".to_string(),
));
}
let uri = fs_path_to_uri(file_path).map_err(|e| {
RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
})?;
let store = self._index.document_store();
let doc = store
.get(&uri)
.ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
let idx = doc.line_index.clone();
let start_off = idx.position_to_offset(start_line as u32 - 1, 0).ok_or_else(|| {
RefactorError::InvalidPosition {
file: file_path.display().to_string(),
details: format!("Invalid start line: {}", start_line),
}
})?;
let end_off = idx.position_to_offset(end_line as u32, 0).unwrap_or(doc.text.len());
let extracted = doc.text[start_off..end_off].to_string();
let original_edits = vec![TextEdit {
start: start_off,
end: end_off,
new_text: format!("use {};\n", module_name),
}];
let new_path = file_path.with_file_name(module_name_to_path(module_name));
let new_edits = vec![TextEdit { start: 0, end: 0, new_text: extracted }];
let file_edits = vec![
FileEdit { file_path: file_path.to_path_buf(), edits: original_edits },
FileEdit { file_path: new_path.clone(), edits: new_edits },
];
Ok(RefactorResult {
file_edits,
description: format!(
"Extract {} lines from {} into module '{}'",
end_line - start_line + 1,
file_path.display(),
module_name
),
warnings: vec![],
})
}
pub fn optimize_imports(&self) -> Result<RefactorResult, String> {
let optimizer = ImportOptimizer::new();
let mut file_edits = Vec::new();
for doc in self._index.document_store().all_documents() {
let Some(path) = uri_to_fs_path(&doc.uri) else { continue };
let analysis = optimizer.analyze_content(&doc.text)?;
let optimized = optimizer.generate_optimized_imports(&analysis);
if optimized.is_empty() {
continue;
}
let (start, end) = if let Some(import_block_re) = get_import_block_regex() {
if let Some(m) = import_block_re.find(&doc.text) {
(m.start(), m.end())
} else {
(0, 0)
}
} else {
(0, 0)
};
file_edits.push(FileEdit {
file_path: path.clone(),
edits: vec![TextEdit { start, end, new_text: format!("{}\n", optimized) }],
});
}
Ok(RefactorResult {
file_edits,
description: "Optimize imports across workspace".to_string(),
warnings: vec![],
})
}
pub fn move_subroutine(
&self,
sub_name: &str,
from_file: &Path,
to_module: &str,
) -> Result<RefactorResult, RefactorError> {
if sub_name.is_empty() {
return Err(RefactorError::InvalidInput("Subroutine name cannot be empty".to_string()));
}
if to_module.is_empty() {
return Err(RefactorError::InvalidInput(
"Target module name cannot be empty".to_string(),
));
}
let uri = fs_path_to_uri(from_file).map_err(|e| {
RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
})?;
let symbols = self._index.file_symbols(&uri);
let sym = symbols.into_iter().find(|s| s.name == sub_name).ok_or_else(|| {
RefactorError::SymbolNotFound {
symbol: sub_name.to_string(),
file: from_file.display().to_string(),
}
})?;
let store = self._index.document_store();
let doc = store
.get(&uri)
.ok_or_else(|| RefactorError::DocumentNotIndexed(from_file.display().to_string()))?;
let idx = doc.line_index.clone();
let start_off = idx
.position_to_offset(sym.range.start.line, sym.range.start.column)
.ok_or_else(|| RefactorError::InvalidPosition {
file: from_file.display().to_string(),
details: format!(
"Invalid start position for subroutine '{}' at line {}, column {}",
sub_name, sym.range.start.line, sym.range.start.column
),
})?;
let end_off =
idx.position_to_offset(sym.range.end.line, sym.range.end.column).ok_or_else(|| {
RefactorError::InvalidPosition {
file: from_file.display().to_string(),
details: format!(
"Invalid end position for subroutine '{}' at line {}, column {}",
sub_name, sym.range.end.line, sym.range.end.column
),
}
})?;
let sub_text = doc.text[start_off..end_off].to_string();
let mut file_edits = vec![FileEdit {
file_path: from_file.to_path_buf(),
edits: vec![TextEdit { start: start_off, end: end_off, new_text: String::new() }],
}];
let target_path = from_file.with_file_name(module_name_to_path(to_module));
let target_uri = fs_path_to_uri(&target_path).map_err(|e| {
RefactorError::UriConversion(format!("Failed to convert target path to URI: {}", e))
})?;
let target_doc = store.get(&target_uri);
let insertion_offset = target_doc.as_ref().map(|d| d.text.len()).unwrap_or(0);
file_edits.push(FileEdit {
file_path: target_path.clone(),
edits: vec![TextEdit {
start: insertion_offset,
end: insertion_offset,
new_text: sub_text,
}],
});
Ok(RefactorResult {
file_edits,
description: format!(
"Move subroutine '{}' from {} to module '{}'",
sub_name,
from_file.display(),
to_module
),
warnings: vec![],
})
}
pub fn inline_variable(
&self,
var_name: &str,
file_path: &Path,
_position: (usize, usize),
) -> Result<RefactorResult, RefactorError> {
let (sigil, bare) = normalize_var(var_name);
let _key = SymbolKey {
pkg: Arc::from("main".to_string()),
name: Arc::from(bare.to_string()),
sigil,
kind: SymKind::Var,
};
if var_name.is_empty() {
return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
}
let uri = fs_path_to_uri(file_path).map_err(|e| {
RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
})?;
let store = self._index.document_store();
let doc = store
.get(&uri)
.ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
let idx = doc.line_index.clone();
let def_line_idx = doc
.text
.lines()
.position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
.ok_or_else(|| RefactorError::SymbolNotFound {
symbol: var_name.to_string(),
file: file_path.display().to_string(),
})?;
let def_line_start = idx.position_to_offset(def_line_idx as u32, 0).ok_or_else(|| {
RefactorError::InvalidPosition {
file: file_path.display().to_string(),
details: format!("Invalid start position for definition line: {}", def_line_idx),
}
})?;
let def_line_end =
idx.position_to_offset(def_line_idx as u32 + 1, 0).unwrap_or(doc.text.len());
let def_line = doc.text.lines().nth(def_line_idx).unwrap_or("");
let expr = def_line
.split('=')
.nth(1)
.map(|s| s.trim().trim_end_matches(';'))
.ok_or_else(|| {
RefactorError::ParseError(format!(
"Variable '{}' has no initializer in line: {}",
var_name, def_line
))
})?
.to_string();
let mut edits_map: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
start: def_line_start,
end: def_line_end,
new_text: String::new(),
});
let mut search_pos = def_line_end;
while let Some(found) = doc.text[search_pos..].find(var_name) {
let start = search_pos + found;
let end = start + var_name.len();
edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
start,
end,
new_text: expr.clone(),
});
search_pos = end;
}
let file_edits =
edits_map.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
Ok(RefactorResult {
file_edits,
description: format!("Inline variable '{}' in {}", var_name, file_path.display()),
warnings: vec![],
})
}
pub fn inline_variable_all(
&self,
var_name: &str,
def_file_path: &Path,
_position: (usize, usize),
) -> Result<RefactorResult, RefactorError> {
if var_name.is_empty() {
return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
}
let (sigil, bare) = normalize_var(var_name);
let key = SymbolKey {
pkg: Arc::from("main".to_string()),
name: Arc::from(bare.to_string()),
sigil,
kind: SymKind::Var,
};
let def_uri = fs_path_to_uri(def_file_path).map_err(|e| {
RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
})?;
let store = self._index.document_store();
let def_doc = store.get(&def_uri).ok_or_else(|| {
RefactorError::DocumentNotIndexed(def_file_path.display().to_string())
})?;
let def_line_idx = def_doc
.text
.lines()
.position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
.ok_or_else(|| RefactorError::SymbolNotFound {
symbol: var_name.to_string(),
file: def_file_path.display().to_string(),
})?;
let def_line = def_doc.text.lines().nth(def_line_idx).unwrap_or("");
let expr = def_line
.split('=')
.nth(1)
.map(|s| s.trim().trim_end_matches(';'))
.ok_or_else(|| {
RefactorError::ParseError(format!(
"Variable '{}' has no initializer in line: {}",
var_name, def_line
))
})?
.to_string();
let mut warnings = Vec::new();
if expr.contains('(') && expr.contains(')') {
warnings.push(format!(
"Warning: Initializer '{}' may contain function calls or side effects",
expr
));
}
let mut all_locations = self._index.find_refs(&key);
if let Some(def_loc) = self._index.find_def(&key) {
if !all_locations.iter().any(|loc| loc.uri == def_loc.uri && loc.range == def_loc.range)
{
all_locations.push(def_loc);
}
}
if all_locations.is_empty() {
for doc in store.all_documents() {
if !doc.text.contains(var_name) {
continue;
}
let idx = doc.line_index.clone();
let mut pos = 0;
while let Some(found) = doc.text[pos..].find(var_name) {
let start = pos + found;
let end = start + var_name.len();
if start >= doc.text.len() || end > doc.text.len() {
break;
}
let (start_line, start_col) = idx.offset_to_position(start);
let (end_line, end_col) = idx.offset_to_position(end);
let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
all_locations.push(crate::workspace_index::Location {
uri: doc.uri.clone(),
range: crate::position::Range {
start: crate::position::Position {
byte: start_byte,
line: start_line,
column: start_col,
},
end: crate::position::Position {
byte: end_byte,
line: end_line,
column: end_col,
},
},
});
pos = end;
if all_locations.len() >= 1000 {
warnings.push(
"Warning: More than 1000 occurrences found, limiting results"
.to_string(),
);
break;
}
}
if all_locations.len() >= 1000 {
break;
}
}
}
let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
let mut total_occurrences = 0;
let mut files_affected = std::collections::HashSet::new();
for loc in all_locations {
let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
})?;
files_affected.insert(path.clone());
if let Some(doc) = store.get(&loc.uri) {
let start_off =
doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
let end_off =
doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
let is_definition = doc.uri == def_uri
&& doc.text[start_off.saturating_sub(10)..start_off.min(doc.text.len())]
.contains("my ");
if is_definition {
let line_start =
doc.text[..start_off].rfind('\n').map(|p| p + 1).unwrap_or(0);
let line_end = doc.text[end_off..]
.find('\n')
.map(|p| end_off + p + 1)
.unwrap_or(doc.text.len());
edits_by_file.entry(path).or_default().push(TextEdit {
start: line_start,
end: line_end,
new_text: String::new(),
});
} else {
edits_by_file.entry(path).or_default().push(TextEdit {
start: start_off,
end: end_off,
new_text: expr.clone(),
});
total_occurrences += 1;
}
}
}
}
let file_edits: Vec<FileEdit> = edits_by_file
.into_iter()
.map(|(file_path, edits)| FileEdit { file_path, edits })
.collect();
let description = format!(
"Inline variable '{}' across workspace: {} occurrences in {} files",
var_name,
total_occurrences,
files_affected.len()
);
Ok(RefactorResult { file_edits, description, warnings })
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::{TempDir, tempdir};
fn setup_index(
files: Vec<(&str, &str)>,
) -> Result<(TempDir, WorkspaceIndex, Vec<PathBuf>), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let mut paths = Vec::new();
let index = WorkspaceIndex::new();
for (name, content) in files {
let path = dir.path().join(name);
std::fs::write(&path, content)?;
let path_str = path.to_str().ok_or_else(|| {
format!("Failed to convert path to string for test file: {}", name)
})?;
index.index_file_str(path_str, content)?;
paths.push(path);
}
Ok((dir, index, paths))
}
#[test]
fn test_rename_symbol() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) =
setup_index(vec![("a.pl", "my $foo = 1; print $foo;"), ("b.pl", "print $foo;")])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.rename_symbol("$foo", "$bar", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
Ok(())
}
#[test]
fn test_extract_module() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
let refactor = WorkspaceRefactor::new(index);
let res = refactor.extract_module(&paths[0], 2, 2, "Extracted")?;
assert_eq!(res.file_edits.len(), 2);
Ok(())
}
#[test]
fn test_extract_module_qualified_name_uses_nested_path()
-> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
let refactor = WorkspaceRefactor::new(index);
let res = refactor.extract_module(&paths[0], 2, 2, "My::Extracted")?;
assert_eq!(res.file_edits.len(), 2);
assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("My/Extracted.pm"));
Ok(())
}
#[test]
fn test_optimize_imports() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, _paths) = setup_index(vec![
("a.pl", "use B;\nuse A;\nuse B;\n"),
("b.pl", "use C;\nuse A;\nuse C;\n"),
])?;
let refactor = WorkspaceRefactor::new(index);
let res = refactor.optimize_imports()?;
assert_eq!(res.file_edits.len(), 2);
Ok(())
}
#[test]
fn test_move_subroutine() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
let refactor = WorkspaceRefactor::new(index);
let res = refactor.move_subroutine("foo", &paths[0], "b")?;
assert_eq!(res.file_edits.len(), 2);
Ok(())
}
#[test]
fn test_move_subroutine_qualified_target_uses_nested_path()
-> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
let refactor = WorkspaceRefactor::new(index);
let res = refactor.move_subroutine("foo", &paths[0], "Target::Module")?;
assert_eq!(res.file_edits.len(), 2);
assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("Target/Module.pm"));
Ok(())
}
#[test]
fn test_inline_variable() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) =
setup_index(vec![("a.pl", "my $x = 42;\nmy $y = $x + 1;\nprint $y;\n")])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable("$x", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
Ok(())
}
#[test]
fn test_rename_symbol_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "my $foo = 1;")])?;
let refactor = WorkspaceRefactor::new(index);
assert!(matches!(
refactor.rename_symbol("", "$bar", &paths[0], (0, 0)),
Err(RefactorError::InvalidInput(_))
));
assert!(matches!(
refactor.rename_symbol("$foo", "", &paths[0], (0, 0)),
Err(RefactorError::InvalidInput(_))
));
assert!(matches!(
refactor.rename_symbol("$foo", "$foo", &paths[0], (0, 0)),
Err(RefactorError::InvalidInput(_))
));
Ok(())
}
#[test]
fn test_extract_module_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
let refactor = WorkspaceRefactor::new(index);
assert!(matches!(
refactor.extract_module(&paths[0], 1, 2, ""),
Err(RefactorError::InvalidInput(_))
));
assert!(matches!(
refactor.extract_module(&paths[0], 5, 2, "Test"),
Err(RefactorError::InvalidInput(_))
));
Ok(())
}
#[test]
fn test_move_subroutine_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo { 1 }")])?;
let refactor = WorkspaceRefactor::new(index);
assert!(matches!(
refactor.move_subroutine("", &paths[0], "Utils"),
Err(RefactorError::InvalidInput(_))
));
assert!(matches!(
refactor.move_subroutine("foo", &paths[0], ""),
Err(RefactorError::InvalidInput(_))
));
Ok(())
}
#[test]
fn test_inline_variable_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 42;")])?;
let refactor = WorkspaceRefactor::new(index);
assert!(matches!(
refactor.inline_variable("", &paths[0], (0, 0)),
Err(RefactorError::InvalidInput(_))
));
Ok(())
}
#[test]
fn test_rename_symbol_unicode_variables() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![
("unicode.pl", "my $♥ = '爱'; print $♥; # Unicode variable"),
("unicode2.pl", "use utf8; my $données = 42; print $données;"), ])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.rename_symbol("$♥", "$love", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
assert!(result.description.contains("♥"));
let result = refactor.rename_symbol("$données", "$data", &paths[1], (0, 0))?;
assert!(!result.file_edits.is_empty());
assert!(result.description.contains("données"));
Ok(())
}
#[test]
fn test_extract_module_unicode_content() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![(
"unicode_content.pl",
"# コメント in Japanese\nmy $message = \"你好世界\";\nprint $message;\n# More 中文 content\n",
)])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.extract_module(&paths[0], 2, 3, "UnicodeUtils")?;
assert_eq!(result.file_edits.len(), 2);
let new_module_edit = &result.file_edits[1];
assert!(new_module_edit.edits[0].new_text.contains("你好世界"));
Ok(())
}
#[test]
fn test_inline_variable_unicode_expressions() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![(
"unicode_expr.pl",
"my $表达式 = \"测试表达式\";\nmy $result = $表达式 . \"suffix\";\nprint $result;\n",
)])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable("$表达式", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
let edits = &result.file_edits[0].edits;
assert!(edits.iter().any(|edit| edit.new_text.contains("测试表达式")));
Ok(())
}
#[test]
fn test_rename_symbol_complex_perl_constructs() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![(
"complex.pl",
r#"
package MyPackage;
my @array = qw($var1 $var2 $var3);
my %hash = ( key1 => $var1, key2 => $var2 );
my $ref = \$var1;
print "Variable in string: $var1\n";
$var1 =~ s/old/new/g;
for my $item (@{[$var1, $var2]}) {
print $item;
}
"#,
)])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.rename_symbol("$var1", "$renamed_var", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
let edits = &result.file_edits[0].edits;
assert!(edits.len() >= 3);
Ok(())
}
#[test]
fn test_extract_module_with_dependencies() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![(
"with_deps.pl",
r#"
use strict;
use warnings;
sub utility_func {
my ($param) = @_;
return "utility result";
}
sub main_func {
my $data = "test data";
my $result = utility_func($data);
print $result;
}
"#,
)])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.extract_module(&paths[0], 5, 8, "Utils")?;
assert_eq!(result.file_edits.len(), 2);
let new_module_edit = &result.file_edits[1];
assert!(new_module_edit.edits[0].new_text.contains("sub utility_func"));
assert!(new_module_edit.edits[0].new_text.contains("utility result"));
Ok(())
}
#[test]
fn test_optimize_imports_complex_scenarios() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, _paths) = setup_index(vec![
(
"complex_imports.pl",
r#"
use strict;
use warnings;
use utf8;
use JSON;
use JSON qw(encode_json);
use YAML;
use YAML qw(Load);
use JSON; # Duplicate
"#,
),
("minimal_imports.pl", "use strict;\nuse warnings;"),
("no_imports.pl", "print 'Hello World';"),
])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.optimize_imports()?;
assert!(result.file_edits.len() <= 3);
for file_edit in &result.file_edits {
assert!(!file_edit.edits.is_empty());
}
Ok(())
}
#[test]
fn test_move_subroutine_not_found() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("empty.pl", "# No subroutines here")])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.move_subroutine("nonexistent", &paths[0], "Target");
assert!(matches!(result, Err(RefactorError::SymbolNotFound { .. })));
Ok(())
}
#[test]
fn test_inline_variable_no_initializer() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) =
setup_index(vec![("no_init.pl", "my $var;\n$var = 42;\nprint $var;\n")])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable("$var", &paths[0], (0, 0));
assert!(matches!(result, Err(RefactorError::ParseError(_))));
Ok(())
}
#[test]
fn test_import_optimization_integration() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, _paths) = setup_index(vec![
(
"with_unused.pl",
"use strict;\nuse warnings;\nuse JSON qw(encode_json unused_symbol);\n\nmy $json = encode_json('test');",
),
("clean.pl", "use strict;\nuse warnings;\n\nprint 'test';"),
])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.optimize_imports()?;
assert!(!result.file_edits.is_empty());
let has_optimizations = result.file_edits.iter().any(|edit| !edit.edits.is_empty());
assert!(has_optimizations);
Ok(())
}
#[test]
fn test_large_file_handling() -> Result<(), Box<dyn std::error::Error>> {
let mut large_content = String::new();
large_content.push_str("my $target = 'value';\n");
for i in 0..100 {
large_content.push_str(&format!("print $target; # Line {}\n", i));
}
let (_dir, index, paths) = setup_index(vec![("large.pl", &large_content)])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.rename_symbol("$target", "$renamed", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
let edits = &result.file_edits[0].edits;
assert_eq!(edits.len(), 101);
Ok(())
}
#[test]
fn test_multiple_files_workspace() -> Result<(), Box<dyn std::error::Error>> {
let files = (0..10)
.map(|i| (format!("file_{}.pl", i), format!("my $shared = {}; print $shared;\n", i)))
.collect::<Vec<_>>();
let files_refs: Vec<_> =
files.iter().map(|(name, content)| (name.as_str(), content.as_str())).collect();
let (_dir, index, paths) = setup_index(files_refs)?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.rename_symbol("$shared", "$common", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
assert!(!result.description.is_empty());
Ok(())
}
#[test]
fn inline_multi_file_basic() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![
("a.pl", "my $const = 42;\nprint $const;\n"),
("b.pl", "print $const;\n"),
("c.pl", "my $result = $const + 1;\n"),
])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable_all("$const", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
assert!(result.description.contains("workspace"));
Ok(())
}
#[test]
fn inline_multi_file_validates_constant() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) =
setup_index(vec![("a.pl", "my $x = get_value();\nprint $x;\n")])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
assert!(!result.warnings.is_empty(), "Should have warning about function call");
Ok(())
}
#[test]
fn inline_multi_file_respects_scope() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![
("a.pl", "package A;\nmy $pkg_var = 10;\nprint $pkg_var;\n"),
("b.pl", "package B;\nmy $pkg_var = 20;\nprint $pkg_var;\n"),
])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable("$pkg_var", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
Ok(())
}
#[test]
fn inline_multi_file_supports_all_types() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![("scalar.pl", "my $x = 42;\nprint $x;\n")])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
assert!(!result.file_edits.is_empty());
Ok(())
}
#[test]
fn inline_multi_file_reports_occurrences() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, index, paths) = setup_index(vec![
("a.pl", "my $x = 42;\nprint $x;\nprint $x;\nprint $x;\n"),
("b.pl", "print $x;\nprint $x;\n"),
])?;
let refactor = WorkspaceRefactor::new(index);
let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
assert!(
result.description.contains("occurrence") || result.description.contains("workspace")
);
Ok(())
}
}