Skip to main content

perl_refactoring/refactor/
workspace_refactor.rs

1//! Workspace-wide refactoring operations for Perl codebases
2//!
3//! This module provides comprehensive refactoring capabilities that span multiple files,
4//! including symbol renaming, module extraction, import optimization, and code movement.
5//! All operations are designed to be safe, reversible, and provide detailed feedback.
6//!
7//! # LSP Workflow Integration
8//!
9//! Refactoring operations support large-scale Perl code maintenance across LSP workflow stages:
10//! - **Parse**: Analyze Perl syntax and extract symbols from source files
11//! - **Index**: Build workspace symbol index and cross-file references
12//! - **Navigate**: Update cross-file dependencies during control flow refactoring
13//! - **Complete**: Maintain symbol completion consistency during code reorganization
14//! - **Analyze**: Update workspace analysis after refactoring operations
15//!
16//! # Performance Characteristics
17//!
18//! Optimized for enterprise-scale Perl development workflows:
19//! - **Large Codebase Support**: Efficient memory management during workspace refactoring
20//! - **Incremental Updates**: Process only changed files to minimize operation time
21//! - **Workspace Indexing**: Leverages comprehensive symbol index for fast cross-file operations
22//! - **Batch Operations**: Groups related changes to minimize file I/O overhead
23//!
24//! # Enterprise Features
25//!
26//! - **Safe Refactoring**: Pre-validation ensures operation safety before applying changes
27//! - **Rollback Support**: All operations can be reversed with detailed change tracking
28//! - **Cross-File Analysis**: Handles complex dependency graphs in multi-file Perl codebases
29//! - **Import Optimization**: Automatically manages import statements during refactoring
30//!
31//! # Usage Examples
32//!
33//! ```rust,ignore
34//! use perl_parser::workspace_refactor::WorkspaceRefactor;
35//! use perl_parser::workspace_index::WorkspaceIndex;
36//!
37//! // Initialize refactoring engine for Perl script workspace
38//! let index = WorkspaceIndex::new();
39//! let refactor = WorkspaceRefactor::new(index);
40//!
41//! // Rename function across all Perl scripts
42//! let result = refactor.rename_symbol(
43//!     "process_data",
44//!     "enhanced_process_data",
45//!     &std::path::Path::new("data_processor.pl"),
46//!     (0, 0)
47//! )?;
48//!
49//! // Apply import optimization across Perl modules
50//! let optimized = refactor.optimize_imports()?;
51//! ```
52
53use crate::import_optimizer::ImportOptimizer;
54use crate::workspace_index::{
55    SymKind, SymbolKey, WorkspaceIndex, fs_path_to_uri, normalize_var, uri_to_fs_path,
56};
57use perl_module_path::module_name_to_path;
58use regex::Regex;
59use serde::{Deserialize, Serialize};
60use std::collections::BTreeMap;
61use std::fmt;
62use std::path::{Path, PathBuf};
63use std::sync::{Arc, OnceLock};
64
65/// Errors that can occur during workspace refactoring operations
66#[derive(Debug, Clone)]
67pub enum RefactorError {
68    /// Failed to convert between file paths and URIs
69    UriConversion(String),
70    /// Document not found in workspace index
71    DocumentNotIndexed(String),
72    /// Invalid position or range in document
73    InvalidPosition {
74        /// The file path where the invalid position occurred
75        file: String,
76        /// Details about why the position is invalid
77        details: String,
78    },
79    /// Symbol not found in workspace
80    SymbolNotFound {
81        /// The name of the symbol that could not be found
82        symbol: String,
83        /// The file path where the symbol lookup was attempted
84        file: String,
85    },
86    /// Failed to parse or analyze code structure
87    ParseError(String),
88    /// Input validation failed
89    InvalidInput(String),
90    /// File system operation failed
91    FileSystemError(String),
92}
93
94impl fmt::Display for RefactorError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            RefactorError::UriConversion(msg) => write!(f, "URI conversion failed: {}", msg),
98            RefactorError::DocumentNotIndexed(file) => {
99                write!(f, "Document not indexed in workspace: {}", file)
100            }
101            RefactorError::InvalidPosition { file, details } => {
102                write!(f, "Invalid position in {}: {}", file, details)
103            }
104            RefactorError::SymbolNotFound { symbol, file } => {
105                write!(f, "Symbol '{}' not found in {}", symbol, file)
106            }
107            RefactorError::ParseError(msg) => write!(f, "Parse error: {}", msg),
108            RefactorError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
109            RefactorError::FileSystemError(msg) => write!(f, "File system error: {}", msg),
110        }
111    }
112}
113
114impl std::error::Error for RefactorError {}
115
116// Move regex outside loop to avoid recompilation
117static IMPORT_BLOCK_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
118
119/// Get the import block regex, returning None if compilation failed
120fn get_import_block_regex() -> Option<&'static Regex> {
121    IMPORT_BLOCK_RE.get_or_init(|| Regex::new(r"(?m)^(?:use\s+[\w:]+[^\n]*\n)+")).as_ref().ok()
122}
123
124/// A file edit as part of a refactoring operation
125///
126/// Represents a set of text edits that should be applied to a single file
127/// as part of a workspace refactoring operation. All edits within a FileEdit
128/// are applied to the same file and should be applied in reverse order
129/// (from end to beginning) to maintain position validity.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct FileEdit {
132    /// The absolute path to the file that should be edited
133    pub file_path: PathBuf,
134    /// The list of text edits to apply to this file, in document order
135    pub edits: Vec<TextEdit>,
136}
137
138/// A single text edit within a file
139///
140/// Represents a single textual change within a document, defined by
141/// a byte range and the replacement text. The range is inclusive of
142/// the start position and exclusive of the end position.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct TextEdit {
145    /// The byte offset of the start of the range to replace (inclusive)
146    pub start: usize,
147    /// The byte offset of the end of the range to replace (exclusive)
148    pub end: usize,
149    /// The text to replace the range with (may be empty for deletion)
150    pub new_text: String,
151}
152
153/// Result of a refactoring operation
154///
155/// Contains all the changes that need to be applied to complete a refactoring
156/// operation, along with descriptive information and any warnings that were
157/// encountered during the analysis phase.
158#[derive(Debug, Serialize, Deserialize)]
159pub struct RefactorResult {
160    /// The list of file edits that need to be applied to complete the refactoring
161    pub file_edits: Vec<FileEdit>,
162    /// A human-readable description of what the refactoring operation does
163    pub description: String,
164    /// Any warnings encountered during the refactoring analysis (non-fatal issues)
165    pub warnings: Vec<String>,
166}
167
168/// Workspace-wide refactoring provider
169///
170/// Provides high-level refactoring operations that can operate across multiple files
171/// within a workspace. Uses a WorkspaceIndex to understand symbol relationships
172/// and dependencies between files.
173///
174/// # Examples
175///
176/// ```rust,ignore
177/// # use perl_parser::workspace_refactor::WorkspaceRefactor;
178/// # use perl_parser::workspace_index::WorkspaceIndex;
179/// # use std::path::Path;
180/// let index = WorkspaceIndex::new();
181/// let refactor = WorkspaceRefactor::new(index);
182///
183/// // Rename a variable across all files
184/// let result = refactor.rename_symbol("$old_name", "$new_name", Path::new("file.pl"), (0, 0));
185/// ```
186pub struct WorkspaceRefactor {
187    /// The workspace index used for symbol lookup and cross-file analysis
188    pub _index: WorkspaceIndex,
189}
190
191impl WorkspaceRefactor {
192    /// Create a new workspace refactoring provider
193    ///
194    /// # Arguments
195    /// * `index` - A WorkspaceIndex containing indexed symbols and documents
196    ///
197    /// # Returns
198    /// A new WorkspaceRefactor instance ready to perform refactoring operations
199    pub fn new(index: WorkspaceIndex) -> Self {
200        Self { _index: index }
201    }
202
203    /// Rename a symbol across all files in the workspace
204    ///
205    /// Performs a comprehensive rename of a Perl symbol (variable, subroutine, or package)
206    /// across all indexed files in the workspace. The operation preserves sigils for
207    /// variables and handles both indexed symbol lookups and text-based fallback searches.
208    ///
209    /// # Arguments
210    /// * `old_name` - The current name of the symbol (e.g., "$variable", "subroutine")
211    /// * `new_name` - The new name for the symbol (e.g., "$new_variable", "new_subroutine")
212    /// * `_file_path` - The file path where the rename was initiated (currently unused)
213    /// * `_position` - The position in the file where the rename was initiated (currently unused)
214    ///
215    /// # Returns
216    /// * `Ok(RefactorResult)` - Contains all file edits needed to complete the rename
217    /// * `Err(RefactorError)` - If validation fails or symbol lookup encounters issues
218    ///
219    /// # Errors
220    /// * `RefactorError::InvalidInput` - If names are empty or identical
221    /// * `RefactorError::UriConversion` - If file path/URI conversion fails
222    ///
223    /// # Examples
224    /// ```rust,ignore
225    /// # use perl_parser::workspace_refactor::WorkspaceRefactor;
226    /// # use perl_parser::workspace_index::WorkspaceIndex;
227    /// # use std::path::Path;
228    /// let index = WorkspaceIndex::new();
229    /// let refactor = WorkspaceRefactor::new(index);
230    ///
231    /// let result = refactor.rename_symbol("$old_var", "$new_var", Path::new("file.pl"), (0, 0))?;
232    /// println!("Rename will affect {} files", result.file_edits.len());
233    /// # Ok::<(), perl_parser::workspace_refactor::RefactorError>(())
234    /// ```
235    pub fn rename_symbol(
236        &self,
237        old_name: &str,
238        new_name: &str,
239        _file_path: &Path,
240        _position: (usize, usize),
241    ) -> Result<RefactorResult, RefactorError> {
242        // Validate input parameters
243        if old_name.is_empty() {
244            return Err(RefactorError::InvalidInput("Symbol name cannot be empty".to_string()));
245        }
246        if new_name.is_empty() {
247            return Err(RefactorError::InvalidInput("New name cannot be empty".to_string()));
248        }
249        if old_name == new_name {
250            return Err(RefactorError::InvalidInput("Old and new names are identical".to_string()));
251        }
252
253        // Infer symbol kind and bare name
254        let (sigil, bare) = normalize_var(old_name);
255        let kind = if sigil.is_some() { SymKind::Var } else { SymKind::Sub };
256
257        // For now assume package 'main'
258        let key = SymbolKey {
259            pkg: Arc::from("main".to_string()),
260            name: Arc::from(bare.to_string()),
261            sigil,
262            kind,
263        };
264
265        let mut edits: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
266
267        // Find all references
268        let mut locations = self._index.find_refs(&key);
269
270        // Always try to include the definition explicitly
271        let def_loc = self._index.find_def(&key);
272        if let Some(def) = def_loc {
273            if !locations.iter().any(|loc| loc.uri == def.uri && loc.range == def.range) {
274                locations.push(def);
275            }
276        }
277
278        let store = self._index.document_store();
279
280        if locations.is_empty() {
281            // Fallback naive search with performance optimizations
282            let _old_name_bytes = old_name.as_bytes();
283
284            for doc in store.all_documents() {
285                // Pre-check if the document even contains the target string to avoid unnecessary work
286                if !doc.text.contains(old_name) {
287                    continue;
288                }
289
290                let idx = doc.line_index.clone();
291                let mut pos = 0;
292                let _text_bytes = doc.text.as_bytes();
293
294                // Use faster byte-based searching with matches iterator
295                while let Some(found) = doc.text[pos..].find(old_name) {
296                    let start = pos + found;
297                    let end = start + old_name.len();
298
299                    // Early bounds checking to avoid invalid positions
300                    if start >= doc.text.len() || end > doc.text.len() {
301                        break;
302                    }
303
304                    let (start_line, start_col) = idx.offset_to_position(start);
305                    let (end_line, end_col) = idx.offset_to_position(end);
306                    let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
307                    let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
308                    locations.push(crate::workspace_index::Location {
309                        uri: doc.uri.clone(),
310                        range: crate::position::Range {
311                            start: crate::position::Position {
312                                byte: start_byte,
313                                line: start_line,
314                                column: start_col,
315                            },
316                            end: crate::position::Position {
317                                byte: end_byte,
318                                line: end_line,
319                                column: end_col,
320                            },
321                        },
322                    });
323                    pos = end;
324
325                    // Limit the number of matches to prevent runaway performance issues
326                    if locations.len() >= 1000 {
327                        break;
328                    }
329                }
330
331                // If we've found matches in this document and it's getting large, we can break early
332                // This is a heuristic to balance completeness with performance
333                if locations.len() >= 500 {
334                    break;
335                }
336            }
337        }
338
339        for loc in locations {
340            let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
341                RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
342            })?;
343            if let Some(doc) = store.get(&loc.uri) {
344                let start_off =
345                    doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
346                let end_off =
347                    doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
348                if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
349                    let replacement = match kind {
350                        SymKind::Var => {
351                            let sig = sigil.unwrap_or('$');
352                            format!("{}{}", sig, new_name.trim_start_matches(['$', '@', '%']))
353                        }
354                        _ => new_name.to_string(),
355                    };
356                    edits.entry(path).or_default().push(TextEdit {
357                        start: start_off,
358                        end: end_off,
359                        new_text: replacement,
360                    });
361                }
362            }
363        }
364
365        let file_edits: Vec<FileEdit> =
366            edits.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
367
368        let description = format!("Rename '{}' to '{}'", old_name, new_name);
369        Ok(RefactorResult { file_edits, description, warnings: vec![] })
370    }
371
372    /// Extract selected code into a new module
373    ///
374    /// Takes a range of lines from an existing file and moves them into a new
375    /// Perl module file, replacing the original code with a `use` statement.
376    /// This is useful for breaking up large files into smaller, more manageable modules.
377    ///
378    /// # Arguments
379    /// * `file_path` - The path to the file containing the code to extract
380    /// * `start_line` - The first line to extract (1-based line number)
381    /// * `end_line` - The last line to extract (1-based line number, inclusive)
382    /// * `module_name` - The name of the new module to create (without .pm extension).
383    ///   Qualified names like `Foo::Bar` are mapped to `Foo/Bar.pm`.
384    ///
385    /// # Returns
386    /// * `Ok(RefactorResult)` - Contains edits for both the original file and new module
387    /// * `Err(RefactorError)` - If validation fails or file operations encounter issues
388    ///
389    /// # Errors
390    /// * `RefactorError::InvalidInput` - If module name is empty or start_line > end_line
391    /// * `RefactorError::DocumentNotIndexed` - If the source file is not in the workspace index
392    /// * `RefactorError::InvalidPosition` - If the line numbers are invalid
393    /// * `RefactorError::UriConversion` - If file path/URI conversion fails
394    ///
395    /// # Examples
396    /// ```rust,ignore
397    /// # use perl_parser::workspace_refactor::WorkspaceRefactor;
398    /// # use perl_parser::workspace_index::WorkspaceIndex;
399    /// # use std::path::Path;
400    /// let index = WorkspaceIndex::new();
401    /// let refactor = WorkspaceRefactor::new(index);
402    ///
403    /// let result = refactor.extract_module(
404    ///     Path::new("large_file.pl"),
405    ///     50, 100,  // Extract lines 50-100
406    ///     "ExtractedUtils"
407    /// )?;
408    /// # Ok::<(), perl_parser::workspace_refactor::RefactorError>(())
409    /// ```
410    pub fn extract_module(
411        &self,
412        file_path: &Path,
413        start_line: usize,
414        end_line: usize,
415        module_name: &str,
416    ) -> Result<RefactorResult, RefactorError> {
417        // Validate input parameters
418        if module_name.is_empty() {
419            return Err(RefactorError::InvalidInput("Module name cannot be empty".to_string()));
420        }
421        if start_line > end_line {
422            return Err(RefactorError::InvalidInput(
423                "Start line cannot be after end line".to_string(),
424            ));
425        }
426
427        let uri = fs_path_to_uri(file_path).map_err(|e| {
428            RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
429        })?;
430        let store = self._index.document_store();
431        let doc = store
432            .get(&uri)
433            .ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
434        let idx = doc.line_index.clone();
435
436        // Determine byte offsets for lines
437        let start_off = idx.position_to_offset(start_line as u32 - 1, 0).ok_or_else(|| {
438            RefactorError::InvalidPosition {
439                file: file_path.display().to_string(),
440                details: format!("Invalid start line: {}", start_line),
441            }
442        })?;
443        let end_off = idx.position_to_offset(end_line as u32, 0).unwrap_or(doc.text.len());
444
445        let extracted = doc.text[start_off..end_off].to_string();
446
447        // Original file edit - replace selection with use statement
448        let original_edits = vec![TextEdit {
449            start: start_off,
450            end: end_off,
451            new_text: format!("use {};\n", module_name),
452        }];
453
454        // New module file content
455        let new_path = file_path.with_file_name(module_name_to_path(module_name));
456        let new_edits = vec![TextEdit { start: 0, end: 0, new_text: extracted }];
457
458        let file_edits = vec![
459            FileEdit { file_path: file_path.to_path_buf(), edits: original_edits },
460            FileEdit { file_path: new_path.clone(), edits: new_edits },
461        ];
462
463        Ok(RefactorResult {
464            file_edits,
465            description: format!(
466                "Extract {} lines from {} into module '{}'",
467                end_line - start_line + 1,
468                file_path.display(),
469                module_name
470            ),
471            warnings: vec![],
472        })
473    }
474
475    /// Optimize imports across the entire workspace
476    ///
477    /// Uses the ImportOptimizer to analyze all files and optimize their import statements by:
478    /// - Detecting unused imports with smart bare import analysis
479    /// - Removing duplicate imports from the same module
480    /// - Sorting imports alphabetically
481    /// - Consolidating multiple imports from the same module
482    /// - Conservative handling of pragma modules and bare imports
483    ///
484    /// # Returns
485    /// * `Ok(RefactorResult)` - Contains all file edits to optimize imports
486    /// * `Err(String)` - If import analysis encounters issues
487    pub fn optimize_imports(&self) -> Result<RefactorResult, String> {
488        let optimizer = ImportOptimizer::new();
489        let mut file_edits = Vec::new();
490
491        // Iterate over all open documents in the workspace
492        for doc in self._index.document_store().all_documents() {
493            let Some(path) = uri_to_fs_path(&doc.uri) else { continue };
494
495            let analysis = optimizer.analyze_content(&doc.text)?;
496            let optimized = optimizer.generate_optimized_imports(&analysis);
497
498            if optimized.is_empty() {
499                continue;
500            }
501
502            // Replace the existing import block at the top of the file
503            let (start, end) = if let Some(import_block_re) = get_import_block_regex() {
504                if let Some(m) = import_block_re.find(&doc.text) {
505                    (m.start(), m.end())
506                } else {
507                    (0, 0)
508                }
509            } else {
510                // Regex compilation failed, insert at beginning
511                (0, 0)
512            };
513
514            file_edits.push(FileEdit {
515                file_path: path.clone(),
516                edits: vec![TextEdit { start, end, new_text: format!("{}\n", optimized) }],
517            });
518        }
519
520        Ok(RefactorResult {
521            file_edits,
522            description: "Optimize imports across workspace".to_string(),
523            warnings: vec![],
524        })
525    }
526
527    /// Move a subroutine from one file to another module
528    ///
529    /// Extracts a subroutine definition from one file and moves it to another module file.
530    /// The subroutine is completely removed from the source file and appended to the
531    /// target module file. This operation does not update callers or add import statements.
532    ///
533    /// # Arguments
534    /// * `sub_name` - The name of the subroutine to move (without 'sub' keyword)
535    /// * `from_file` - The source file containing the subroutine
536    /// * `to_module` - The name of the target module (without .pm extension).
537    ///   Qualified names like `Foo::Bar` are mapped to `Foo/Bar.pm`.
538    ///
539    /// # Returns
540    /// * `Ok(RefactorResult)` - Contains edits for both source and target files
541    /// * `Err(RefactorError)` - If validation fails or the subroutine cannot be found
542    ///
543    /// # Errors
544    /// * `RefactorError::InvalidInput` - If names are empty
545    /// * `RefactorError::DocumentNotIndexed` - If the source file is not indexed
546    /// * `RefactorError::SymbolNotFound` - If the subroutine is not found in the source file
547    /// * `RefactorError::InvalidPosition` - If the subroutine's position is invalid
548    /// * `RefactorError::UriConversion` - If file path/URI conversion fails
549    ///
550    /// # Examples
551    /// ```rust,ignore
552    /// # use perl_parser::workspace_refactor::WorkspaceRefactor;
553    /// # use perl_parser::workspace_index::WorkspaceIndex;
554    /// # use std::path::Path;
555    /// let index = WorkspaceIndex::new();
556    /// let refactor = WorkspaceRefactor::new(index);
557    ///
558    /// let result = refactor.move_subroutine(
559    ///     "utility_function",
560    ///     Path::new("main.pl"),
561    ///     "Utils"
562    /// )?;
563    /// # Ok::<(), perl_parser::workspace_refactor::RefactorError>(())
564    /// ```
565    pub fn move_subroutine(
566        &self,
567        sub_name: &str,
568        from_file: &Path,
569        to_module: &str,
570    ) -> Result<RefactorResult, RefactorError> {
571        // Validate input parameters
572        if sub_name.is_empty() {
573            return Err(RefactorError::InvalidInput("Subroutine name cannot be empty".to_string()));
574        }
575        if to_module.is_empty() {
576            return Err(RefactorError::InvalidInput(
577                "Target module name cannot be empty".to_string(),
578            ));
579        }
580
581        let uri = fs_path_to_uri(from_file).map_err(|e| {
582            RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
583        })?;
584        let symbols = self._index.file_symbols(&uri);
585        let sym = symbols.into_iter().find(|s| s.name == sub_name).ok_or_else(|| {
586            RefactorError::SymbolNotFound {
587                symbol: sub_name.to_string(),
588                file: from_file.display().to_string(),
589            }
590        })?;
591
592        let store = self._index.document_store();
593        let doc = store
594            .get(&uri)
595            .ok_or_else(|| RefactorError::DocumentNotIndexed(from_file.display().to_string()))?;
596        let idx = doc.line_index.clone();
597        let start_off = idx
598            .position_to_offset(sym.range.start.line, sym.range.start.column)
599            .ok_or_else(|| RefactorError::InvalidPosition {
600                file: from_file.display().to_string(),
601                details: format!(
602                    "Invalid start position for subroutine '{}' at line {}, column {}",
603                    sub_name, sym.range.start.line, sym.range.start.column
604                ),
605            })?;
606        let end_off =
607            idx.position_to_offset(sym.range.end.line, sym.range.end.column).ok_or_else(|| {
608                RefactorError::InvalidPosition {
609                    file: from_file.display().to_string(),
610                    details: format!(
611                        "Invalid end position for subroutine '{}' at line {}, column {}",
612                        sub_name, sym.range.end.line, sym.range.end.column
613                    ),
614                }
615            })?;
616        let sub_text = doc.text[start_off..end_off].to_string();
617
618        // Remove from original file
619        let mut file_edits = vec![FileEdit {
620            file_path: from_file.to_path_buf(),
621            edits: vec![TextEdit { start: start_off, end: end_off, new_text: String::new() }],
622        }];
623
624        // Append to new module file
625        let target_path = from_file.with_file_name(module_name_to_path(to_module));
626        let target_uri = fs_path_to_uri(&target_path).map_err(|e| {
627            RefactorError::UriConversion(format!("Failed to convert target path to URI: {}", e))
628        })?;
629        let target_doc = store.get(&target_uri);
630        let insertion_offset = target_doc.as_ref().map(|d| d.text.len()).unwrap_or(0);
631
632        file_edits.push(FileEdit {
633            file_path: target_path.clone(),
634            edits: vec![TextEdit {
635                start: insertion_offset,
636                end: insertion_offset,
637                new_text: sub_text,
638            }],
639        });
640
641        Ok(RefactorResult {
642            file_edits,
643            description: format!(
644                "Move subroutine '{}' from {} to module '{}'",
645                sub_name,
646                from_file.display(),
647                to_module
648            ),
649            warnings: vec![],
650        })
651    }
652
653    /// Inline a variable across its scope
654    ///
655    /// Replaces all occurrences of a variable with its initializer expression
656    /// and removes the variable declaration. This is useful for eliminating
657    /// unnecessary intermediate variables that only serve to store simple expressions.
658    ///
659    /// **Note**: This is a naive implementation that uses simple text matching.
660    /// It may not handle all scoping rules correctly and should be used with caution.
661    ///
662    /// # Arguments
663    /// * `var_name` - The name of the variable to inline (including sigil, e.g., "$temp")
664    /// * `file_path` - The file containing the variable to inline
665    /// * `_position` - The position in the file (currently unused)
666    ///
667    /// # Returns
668    /// * `Ok(RefactorResult)` - Contains the file edits to inline the variable
669    /// * `Err(RefactorError)` - If validation fails or the variable cannot be found
670    ///
671    /// # Errors
672    /// * `RefactorError::InvalidInput` - If the variable name is empty
673    /// * `RefactorError::DocumentNotIndexed` - If the file is not indexed
674    /// * `RefactorError::SymbolNotFound` - If the variable definition is not found
675    /// * `RefactorError::ParseError` - If the variable has no initializer
676    /// * `RefactorError::UriConversion` - If file path/URI conversion fails
677    ///
678    /// # Examples
679    /// ```rust,ignore
680    /// # use perl_parser::workspace_refactor::WorkspaceRefactor;
681    /// # use perl_parser::workspace_index::WorkspaceIndex;
682    /// # use std::path::Path;
683    /// let index = WorkspaceIndex::new();
684    /// let refactor = WorkspaceRefactor::new(index);
685    ///
686    /// // Inline a temporary variable like: my $temp = some_function(); print $temp;
687    /// let result = refactor.inline_variable("$temp", Path::new("file.pl"), (0, 0))?;
688    /// # Ok::<(), perl_parser::workspace_refactor::RefactorError>(())
689    /// ```
690    pub fn inline_variable(
691        &self,
692        var_name: &str,
693        file_path: &Path,
694        _position: (usize, usize),
695    ) -> Result<RefactorResult, RefactorError> {
696        let (sigil, bare) = normalize_var(var_name);
697        let _key = SymbolKey {
698            pkg: Arc::from("main".to_string()),
699            name: Arc::from(bare.to_string()),
700            sigil,
701            kind: SymKind::Var,
702        };
703
704        // Validate input parameters
705        if var_name.is_empty() {
706            return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
707        }
708
709        let uri = fs_path_to_uri(file_path).map_err(|e| {
710            RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
711        })?;
712        let store = self._index.document_store();
713        let doc = store
714            .get(&uri)
715            .ok_or_else(|| RefactorError::DocumentNotIndexed(file_path.display().to_string()))?;
716        let idx = doc.line_index.clone();
717
718        // Naively find definition line (variable declaration with "my")
719        let def_line_idx = doc
720            .text
721            .lines()
722            .position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
723            .ok_or_else(|| RefactorError::SymbolNotFound {
724                symbol: var_name.to_string(),
725                file: file_path.display().to_string(),
726            })?;
727        let def_line_start = idx.position_to_offset(def_line_idx as u32, 0).ok_or_else(|| {
728            RefactorError::InvalidPosition {
729                file: file_path.display().to_string(),
730                details: format!("Invalid start position for definition line: {}", def_line_idx),
731            }
732        })?;
733        let def_line_end =
734            idx.position_to_offset(def_line_idx as u32 + 1, 0).unwrap_or(doc.text.len());
735        let def_line = doc.text.lines().nth(def_line_idx).unwrap_or("");
736        let expr = def_line
737            .split('=')
738            .nth(1)
739            .map(|s| s.trim().trim_end_matches(';'))
740            .ok_or_else(|| {
741                RefactorError::ParseError(format!(
742                    "Variable '{}' has no initializer in line: {}",
743                    var_name, def_line
744                ))
745            })?
746            .to_string();
747
748        let mut edits_map: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
749
750        // Remove definition line
751        edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
752            start: def_line_start,
753            end: def_line_end,
754            new_text: String::new(),
755        });
756
757        // Replace remaining occurrences
758        let mut search_pos = def_line_end;
759        while let Some(found) = doc.text[search_pos..].find(var_name) {
760            let start = search_pos + found;
761            let end = start + var_name.len();
762            edits_map.entry(file_path.to_path_buf()).or_default().push(TextEdit {
763                start,
764                end,
765                new_text: expr.clone(),
766            });
767            search_pos = end;
768        }
769
770        let file_edits =
771            edits_map.into_iter().map(|(file_path, edits)| FileEdit { file_path, edits }).collect();
772
773        Ok(RefactorResult {
774            file_edits,
775            description: format!("Inline variable '{}' in {}", var_name, file_path.display()),
776            warnings: vec![],
777        })
778    }
779
780    /// Inline a variable across all files in the workspace
781    ///
782    /// Replaces all occurrences of a variable with its initializer expression
783    /// across all files in the workspace and removes the variable declaration.
784    ///
785    /// # Arguments
786    /// * `var_name` - The name of the variable to inline (including sigil)
787    /// * `def_file_path` - The file containing the variable definition
788    /// * `_position` - The position in the definition file
789    ///
790    /// # Returns
791    /// Contains all file edits to inline the variable across workspace
792    pub fn inline_variable_all(
793        &self,
794        var_name: &str,
795        def_file_path: &Path,
796        _position: (usize, usize),
797    ) -> Result<RefactorResult, RefactorError> {
798        if var_name.is_empty() {
799            return Err(RefactorError::InvalidInput("Variable name cannot be empty".to_string()));
800        }
801
802        let (sigil, bare) = normalize_var(var_name);
803        let key = SymbolKey {
804            pkg: Arc::from("main".to_string()),
805            name: Arc::from(bare.to_string()),
806            sigil,
807            kind: SymKind::Var,
808        };
809
810        let def_uri = fs_path_to_uri(def_file_path).map_err(|e| {
811            RefactorError::UriConversion(format!("Failed to convert path to URI: {}", e))
812        })?;
813        let store = self._index.document_store();
814        let def_doc = store.get(&def_uri).ok_or_else(|| {
815            RefactorError::DocumentNotIndexed(def_file_path.display().to_string())
816        })?;
817
818        let def_line_idx = def_doc
819            .text
820            .lines()
821            .position(|l| l.trim_start().starts_with("my ") && l.contains(var_name))
822            .ok_or_else(|| RefactorError::SymbolNotFound {
823                symbol: var_name.to_string(),
824                file: def_file_path.display().to_string(),
825            })?;
826
827        let def_line = def_doc.text.lines().nth(def_line_idx).unwrap_or("");
828
829        let expr = def_line
830            .split('=')
831            .nth(1)
832            .map(|s| s.trim().trim_end_matches(';'))
833            .ok_or_else(|| {
834                RefactorError::ParseError(format!(
835                    "Variable '{}' has no initializer in line: {}",
836                    var_name, def_line
837                ))
838            })?
839            .to_string();
840
841        let mut warnings = Vec::new();
842
843        if expr.contains('(') && expr.contains(')') {
844            warnings.push(format!(
845                "Warning: Initializer '{}' may contain function calls or side effects",
846                expr
847            ));
848        }
849
850        let mut all_locations = self._index.find_refs(&key);
851
852        if let Some(def_loc) = self._index.find_def(&key) {
853            if !all_locations.iter().any(|loc| loc.uri == def_loc.uri && loc.range == def_loc.range)
854            {
855                all_locations.push(def_loc);
856            }
857        }
858
859        if all_locations.is_empty() {
860            for doc in store.all_documents() {
861                if !doc.text.contains(var_name) {
862                    continue;
863                }
864
865                let idx = doc.line_index.clone();
866                let mut pos = 0;
867
868                while let Some(found) = doc.text[pos..].find(var_name) {
869                    let start = pos + found;
870                    let end = start + var_name.len();
871
872                    if start >= doc.text.len() || end > doc.text.len() {
873                        break;
874                    }
875
876                    let (start_line, start_col) = idx.offset_to_position(start);
877                    let (end_line, end_col) = idx.offset_to_position(end);
878                    let start_byte = idx.position_to_offset(start_line, start_col).unwrap_or(0);
879                    let end_byte = idx.position_to_offset(end_line, end_col).unwrap_or(0);
880
881                    all_locations.push(crate::workspace_index::Location {
882                        uri: doc.uri.clone(),
883                        range: crate::position::Range {
884                            start: crate::position::Position {
885                                byte: start_byte,
886                                line: start_line,
887                                column: start_col,
888                            },
889                            end: crate::position::Position {
890                                byte: end_byte,
891                                line: end_line,
892                                column: end_col,
893                            },
894                        },
895                    });
896                    pos = end;
897
898                    if all_locations.len() >= 1000 {
899                        warnings.push(
900                            "Warning: More than 1000 occurrences found, limiting results"
901                                .to_string(),
902                        );
903                        break;
904                    }
905                }
906
907                if all_locations.len() >= 1000 {
908                    break;
909                }
910            }
911        }
912
913        let mut edits_by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
914        let mut total_occurrences = 0;
915        let mut files_affected = std::collections::HashSet::new();
916
917        for loc in all_locations {
918            let path = uri_to_fs_path(&loc.uri).ok_or_else(|| {
919                RefactorError::UriConversion(format!("Failed to convert URI to path: {}", loc.uri))
920            })?;
921
922            files_affected.insert(path.clone());
923
924            if let Some(doc) = store.get(&loc.uri) {
925                let start_off =
926                    doc.line_index.position_to_offset(loc.range.start.line, loc.range.start.column);
927                let end_off =
928                    doc.line_index.position_to_offset(loc.range.end.line, loc.range.end.column);
929
930                if let (Some(start_off), Some(end_off)) = (start_off, end_off) {
931                    let is_definition = doc.uri == def_uri
932                        && doc.text[start_off.saturating_sub(10)..start_off.min(doc.text.len())]
933                            .contains("my ");
934
935                    if is_definition {
936                        let line_start =
937                            doc.text[..start_off].rfind('\n').map(|p| p + 1).unwrap_or(0);
938                        let line_end = doc.text[end_off..]
939                            .find('\n')
940                            .map(|p| end_off + p + 1)
941                            .unwrap_or(doc.text.len());
942
943                        edits_by_file.entry(path).or_default().push(TextEdit {
944                            start: line_start,
945                            end: line_end,
946                            new_text: String::new(),
947                        });
948                    } else {
949                        edits_by_file.entry(path).or_default().push(TextEdit {
950                            start: start_off,
951                            end: end_off,
952                            new_text: expr.clone(),
953                        });
954                        total_occurrences += 1;
955                    }
956                }
957            }
958        }
959
960        let file_edits: Vec<FileEdit> = edits_by_file
961            .into_iter()
962            .map(|(file_path, edits)| FileEdit { file_path, edits })
963            .collect();
964
965        let description = format!(
966            "Inline variable '{}' across workspace: {} occurrences in {} files",
967            var_name,
968            total_occurrences,
969            files_affected.len()
970        );
971
972        Ok(RefactorResult { file_edits, description, warnings })
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use tempfile::{TempDir, tempdir};
980
981    fn setup_index(
982        files: Vec<(&str, &str)>,
983    ) -> Result<(TempDir, WorkspaceIndex, Vec<PathBuf>), Box<dyn std::error::Error>> {
984        let dir = tempdir()?;
985        let mut paths = Vec::new();
986        let index = WorkspaceIndex::new();
987        for (name, content) in files {
988            let path = dir.path().join(name);
989            std::fs::write(&path, content)?;
990            let path_str = path.to_str().ok_or_else(|| {
991                format!("Failed to convert path to string for test file: {}", name)
992            })?;
993            index.index_file_str(path_str, content)?;
994            paths.push(path);
995        }
996        Ok((dir, index, paths))
997    }
998
999    #[test]
1000    fn test_rename_symbol() -> Result<(), Box<dyn std::error::Error>> {
1001        let (_dir, index, paths) =
1002            setup_index(vec![("a.pl", "my $foo = 1; print $foo;"), ("b.pl", "print $foo;")])?;
1003        let refactor = WorkspaceRefactor::new(index);
1004        let result = refactor.rename_symbol("$foo", "$bar", &paths[0], (0, 0))?;
1005        assert!(!result.file_edits.is_empty());
1006        Ok(())
1007    }
1008
1009    #[test]
1010    fn test_extract_module() -> Result<(), Box<dyn std::error::Error>> {
1011        let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1012        let refactor = WorkspaceRefactor::new(index);
1013        let res = refactor.extract_module(&paths[0], 2, 2, "Extracted")?;
1014        assert_eq!(res.file_edits.len(), 2);
1015        Ok(())
1016    }
1017
1018    #[test]
1019    fn test_extract_module_qualified_name_uses_nested_path()
1020    -> Result<(), Box<dyn std::error::Error>> {
1021        let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1022        let refactor = WorkspaceRefactor::new(index);
1023        let res = refactor.extract_module(&paths[0], 2, 2, "My::Extracted")?;
1024        assert_eq!(res.file_edits.len(), 2);
1025        assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("My/Extracted.pm"));
1026        Ok(())
1027    }
1028
1029    #[test]
1030    fn test_optimize_imports() -> Result<(), Box<dyn std::error::Error>> {
1031        let (_dir, index, _paths) = setup_index(vec![
1032            ("a.pl", "use B;\nuse A;\nuse B;\n"),
1033            ("b.pl", "use C;\nuse A;\nuse C;\n"),
1034        ])?;
1035        let refactor = WorkspaceRefactor::new(index);
1036        let res = refactor.optimize_imports()?;
1037        assert_eq!(res.file_edits.len(), 2);
1038        Ok(())
1039    }
1040
1041    #[test]
1042    fn test_move_subroutine() -> Result<(), Box<dyn std::error::Error>> {
1043        let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1044        let refactor = WorkspaceRefactor::new(index);
1045        let res = refactor.move_subroutine("foo", &paths[0], "b")?;
1046        assert_eq!(res.file_edits.len(), 2);
1047        Ok(())
1048    }
1049
1050    #[test]
1051    fn test_move_subroutine_qualified_target_uses_nested_path()
1052    -> Result<(), Box<dyn std::error::Error>> {
1053        let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo {1}\n"), ("b.pm", "")])?;
1054        let refactor = WorkspaceRefactor::new(index);
1055        let res = refactor.move_subroutine("foo", &paths[0], "Target::Module")?;
1056        assert_eq!(res.file_edits.len(), 2);
1057        assert_eq!(res.file_edits[1].file_path, paths[0].with_file_name("Target/Module.pm"));
1058        Ok(())
1059    }
1060
1061    #[test]
1062    fn test_inline_variable() -> Result<(), Box<dyn std::error::Error>> {
1063        let (_dir, index, paths) =
1064            setup_index(vec![("a.pl", "my $x = 42;\nmy $y = $x + 1;\nprint $y;\n")])?;
1065        let refactor = WorkspaceRefactor::new(index);
1066        let result = refactor.inline_variable("$x", &paths[0], (0, 0))?;
1067        assert!(!result.file_edits.is_empty());
1068        Ok(())
1069    }
1070
1071    // Edge case and error handling tests
1072    #[test]
1073    fn test_rename_symbol_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1074        let (_dir, index, paths) = setup_index(vec![("a.pl", "my $foo = 1;")])?;
1075        let refactor = WorkspaceRefactor::new(index);
1076
1077        // Empty old name
1078        assert!(matches!(
1079            refactor.rename_symbol("", "$bar", &paths[0], (0, 0)),
1080            Err(RefactorError::InvalidInput(_))
1081        ));
1082
1083        // Empty new name
1084        assert!(matches!(
1085            refactor.rename_symbol("$foo", "", &paths[0], (0, 0)),
1086            Err(RefactorError::InvalidInput(_))
1087        ));
1088
1089        // Identical names
1090        assert!(matches!(
1091            refactor.rename_symbol("$foo", "$foo", &paths[0], (0, 0)),
1092            Err(RefactorError::InvalidInput(_))
1093        ));
1094        Ok(())
1095    }
1096
1097    #[test]
1098    fn test_extract_module_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1099        let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 1;\nprint $x;\n")])?;
1100        let refactor = WorkspaceRefactor::new(index);
1101
1102        // Empty module name
1103        assert!(matches!(
1104            refactor.extract_module(&paths[0], 1, 2, ""),
1105            Err(RefactorError::InvalidInput(_))
1106        ));
1107
1108        // Invalid line range
1109        assert!(matches!(
1110            refactor.extract_module(&paths[0], 5, 2, "Test"),
1111            Err(RefactorError::InvalidInput(_))
1112        ));
1113        Ok(())
1114    }
1115
1116    #[test]
1117    fn test_move_subroutine_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1118        let (_dir, index, paths) = setup_index(vec![("a.pl", "sub foo { 1 }")])?;
1119        let refactor = WorkspaceRefactor::new(index);
1120
1121        // Empty subroutine name
1122        assert!(matches!(
1123            refactor.move_subroutine("", &paths[0], "Utils"),
1124            Err(RefactorError::InvalidInput(_))
1125        ));
1126
1127        // Empty target module
1128        assert!(matches!(
1129            refactor.move_subroutine("foo", &paths[0], ""),
1130            Err(RefactorError::InvalidInput(_))
1131        ));
1132        Ok(())
1133    }
1134
1135    #[test]
1136    fn test_inline_variable_validation_errors() -> Result<(), Box<dyn std::error::Error>> {
1137        let (_dir, index, paths) = setup_index(vec![("a.pl", "my $x = 42;")])?;
1138        let refactor = WorkspaceRefactor::new(index);
1139
1140        // Empty variable name
1141        assert!(matches!(
1142            refactor.inline_variable("", &paths[0], (0, 0)),
1143            Err(RefactorError::InvalidInput(_))
1144        ));
1145        Ok(())
1146    }
1147
1148    // Unicode and international character tests
1149    #[test]
1150    fn test_rename_symbol_unicode_variables() -> Result<(), Box<dyn std::error::Error>> {
1151        let (_dir, index, paths) = setup_index(vec![
1152            ("unicode.pl", "my $♥ = '爱'; print $♥; # Unicode variable"),
1153            ("unicode2.pl", "use utf8; my $données = 42; print $données;"), // French accents
1154        ])?;
1155        let refactor = WorkspaceRefactor::new(index);
1156
1157        // Rename Unicode variable
1158        let result = refactor.rename_symbol("$♥", "$love", &paths[0], (0, 0))?;
1159        assert!(!result.file_edits.is_empty());
1160        assert!(result.description.contains("♥"));
1161
1162        // Rename variable with accents
1163        let result = refactor.rename_symbol("$données", "$data", &paths[1], (0, 0))?;
1164        assert!(!result.file_edits.is_empty());
1165        assert!(result.description.contains("données"));
1166        Ok(())
1167    }
1168
1169    #[test]
1170    fn test_extract_module_unicode_content() -> Result<(), Box<dyn std::error::Error>> {
1171        let (_dir, index, paths) = setup_index(vec![(
1172            "unicode_content.pl",
1173            "# コメント in Japanese\nmy $message = \"你好世界\";\nprint $message;\n# More 中文 content\n",
1174        )])?;
1175        let refactor = WorkspaceRefactor::new(index);
1176
1177        let result = refactor.extract_module(&paths[0], 2, 3, "UnicodeUtils")?;
1178        assert_eq!(result.file_edits.len(), 2); // Original + new module
1179
1180        // Check that the extracted content contains Unicode
1181        let new_module_edit = &result.file_edits[1];
1182        assert!(new_module_edit.edits[0].new_text.contains("你好世界"));
1183        Ok(())
1184    }
1185
1186    #[test]
1187    fn test_inline_variable_unicode_expressions() -> Result<(), Box<dyn std::error::Error>> {
1188        let (_dir, index, paths) = setup_index(vec![(
1189            "unicode_expr.pl",
1190            "my $表达式 = \"测试表达式\";\nmy $result = $表达式 . \"suffix\";\nprint $result;\n",
1191        )])?;
1192        let refactor = WorkspaceRefactor::new(index);
1193
1194        let result = refactor.inline_variable("$表达式", &paths[0], (0, 0))?;
1195        assert!(!result.file_edits.is_empty());
1196
1197        // Check that the replacement contains the Unicode string literal
1198        let edits = &result.file_edits[0].edits;
1199        assert!(edits.iter().any(|edit| edit.new_text.contains("测试表达式")));
1200        Ok(())
1201    }
1202
1203    // Complex edge cases
1204    #[test]
1205    fn test_rename_symbol_complex_perl_constructs() -> Result<(), Box<dyn std::error::Error>> {
1206        let (_dir, index, paths) = setup_index(vec![(
1207            "complex.pl",
1208            r#"
1209package MyPackage;
1210my @array = qw($var1 $var2 $var3);
1211my %hash = ( key1 => $var1, key2 => $var2 );
1212my $ref = \$var1;
1213print "Variable in string: $var1\n";
1214$var1 =~ s/old/new/g;
1215for my $item (@{[$var1, $var2]}) {
1216    print $item;
1217}
1218"#,
1219        )])?;
1220        let refactor = WorkspaceRefactor::new(index);
1221
1222        let result = refactor.rename_symbol("$var1", "$renamed_var", &paths[0], (0, 0))?;
1223        assert!(!result.file_edits.is_empty());
1224
1225        // Check number of edits (should be at least 3: definition and usages)
1226        let edits = &result.file_edits[0].edits;
1227        assert!(edits.len() >= 3);
1228        Ok(())
1229    }
1230
1231    #[test]
1232    fn test_extract_module_with_dependencies() -> Result<(), Box<dyn std::error::Error>> {
1233        let (_dir, index, paths) = setup_index(vec![(
1234            "with_deps.pl",
1235            r#"
1236use strict;
1237use warnings;
1238
1239sub utility_func {
1240    my ($param) = @_;
1241    return "utility result";
1242}
1243
1244sub main_func {
1245    my $data = "test data";
1246    my $result = utility_func($data);
1247    print $result;
1248}
1249"#,
1250        )])?;
1251        let refactor = WorkspaceRefactor::new(index);
1252
1253        let result = refactor.extract_module(&paths[0], 5, 8, "Utils")?;
1254        assert_eq!(result.file_edits.len(), 2);
1255
1256        // Check that extracted content includes the subroutine
1257        let new_module_edit = &result.file_edits[1];
1258        assert!(new_module_edit.edits[0].new_text.contains("sub utility_func"));
1259        assert!(new_module_edit.edits[0].new_text.contains("utility result"));
1260        Ok(())
1261    }
1262
1263    #[test]
1264    fn test_optimize_imports_complex_scenarios() -> Result<(), Box<dyn std::error::Error>> {
1265        let (_dir, index, _paths) = setup_index(vec![
1266            (
1267                "complex_imports.pl",
1268                r#"
1269use strict;
1270use warnings;
1271use utf8;
1272use JSON;
1273use JSON qw(encode_json);
1274use YAML;
1275use YAML qw(Load);
1276use JSON; # Duplicate
1277"#,
1278            ),
1279            ("minimal_imports.pl", "use strict;\nuse warnings;"),
1280            ("no_imports.pl", "print 'Hello World';"),
1281        ])?;
1282        let refactor = WorkspaceRefactor::new(index);
1283
1284        let result = refactor.optimize_imports()?;
1285
1286        // Should optimize the complex file, skip minimal (no duplicates), skip no imports
1287        assert!(result.file_edits.len() <= 3);
1288
1289        // Check that we don't create empty edits for files with no imports
1290        for file_edit in &result.file_edits {
1291            assert!(!file_edit.edits.is_empty());
1292        }
1293        Ok(())
1294    }
1295
1296    #[test]
1297    fn test_move_subroutine_not_found() -> Result<(), Box<dyn std::error::Error>> {
1298        let (_dir, index, paths) = setup_index(vec![("empty.pl", "# No subroutines here")])?;
1299        let refactor = WorkspaceRefactor::new(index);
1300
1301        let result = refactor.move_subroutine("nonexistent", &paths[0], "Target");
1302        assert!(matches!(result, Err(RefactorError::SymbolNotFound { .. })));
1303        Ok(())
1304    }
1305
1306    #[test]
1307    fn test_inline_variable_no_initializer() -> Result<(), Box<dyn std::error::Error>> {
1308        let (_dir, index, paths) =
1309            setup_index(vec![("no_init.pl", "my $var;\n$var = 42;\nprint $var;\n")])?;
1310        let refactor = WorkspaceRefactor::new(index);
1311
1312        let result = refactor.inline_variable("$var", &paths[0], (0, 0));
1313        // Should fail because the found line "my $var;" doesn't have an initializer after =
1314        assert!(matches!(result, Err(RefactorError::ParseError(_))));
1315        Ok(())
1316    }
1317
1318    #[test]
1319    fn test_import_optimization_integration() -> Result<(), Box<dyn std::error::Error>> {
1320        // Test the integration between workspace refactor and import optimizer
1321        let (_dir, index, _paths) = setup_index(vec![
1322            (
1323                "with_unused.pl",
1324                "use strict;\nuse warnings;\nuse JSON qw(encode_json unused_symbol);\n\nmy $json = encode_json('test');",
1325            ),
1326            ("clean.pl", "use strict;\nuse warnings;\n\nprint 'test';"),
1327        ])?;
1328        let refactor = WorkspaceRefactor::new(index);
1329
1330        let result = refactor.optimize_imports()?;
1331
1332        // Should only optimize files that have optimizations available
1333        // Files with unused imports should get optimized edits
1334        assert!(!result.file_edits.is_empty());
1335
1336        // Check that we actually have some optimization suggestions
1337        let has_optimizations = result.file_edits.iter().any(|edit| !edit.edits.is_empty());
1338        assert!(has_optimizations);
1339        Ok(())
1340    }
1341
1342    // Performance and scalability tests
1343    #[test]
1344    fn test_large_file_handling() -> Result<(), Box<dyn std::error::Error>> {
1345        // Create a large file with many occurrences
1346        let mut large_content = String::new();
1347        large_content.push_str("my $target = 'value';\n");
1348        for i in 0..100 {
1349            large_content.push_str(&format!("print $target; # Line {}\n", i));
1350        }
1351
1352        let (_dir, index, paths) = setup_index(vec![("large.pl", &large_content)])?;
1353        let refactor = WorkspaceRefactor::new(index);
1354
1355        let result = refactor.rename_symbol("$target", "$renamed", &paths[0], (0, 0))?;
1356        assert!(!result.file_edits.is_empty());
1357
1358        // With definition included, should have 101 edits (100 usages + 1 definition)
1359        let edits = &result.file_edits[0].edits;
1360        assert_eq!(edits.len(), 101);
1361        Ok(())
1362    }
1363
1364    #[test]
1365    fn test_multiple_files_workspace() -> Result<(), Box<dyn std::error::Error>> {
1366        let files = (0..10)
1367            .map(|i| (format!("file_{}.pl", i), format!("my $shared = {}; print $shared;\n", i)))
1368            .collect::<Vec<_>>();
1369
1370        let files_refs: Vec<_> =
1371            files.iter().map(|(name, content)| (name.as_str(), content.as_str())).collect();
1372        let (_dir, index, paths) = setup_index(files_refs)?;
1373        let refactor = WorkspaceRefactor::new(index);
1374
1375        let result = refactor.rename_symbol("$shared", "$common", &paths[0], (0, 0))?;
1376        assert!(!result.file_edits.is_empty());
1377
1378        // Should potentially affect multiple files if fallback search is used
1379        assert!(!result.description.is_empty());
1380        Ok(())
1381    }
1382
1383    // AC1: Test multi-file occurrence inlining
1384    #[test]
1385    fn inline_multi_file_basic() -> Result<(), Box<dyn std::error::Error>> {
1386        // AC1: When all_occurrences is true, engine finds all references across workspace files
1387        let (_dir, index, paths) = setup_index(vec![
1388            ("a.pl", "my $const = 42;\nprint $const;\n"),
1389            ("b.pl", "print $const;\n"),
1390            ("c.pl", "my $result = $const + 1;\n"),
1391        ])?;
1392        let refactor = WorkspaceRefactor::new(index);
1393        let result = refactor.inline_variable_all("$const", &paths[0], (0, 0))?;
1394
1395        // Should affect all files where $const is used
1396        assert!(!result.file_edits.is_empty());
1397        assert!(result.description.contains("workspace"));
1398        Ok(())
1399    }
1400
1401    // AC2: Test safety validation for constant values
1402    #[test]
1403    fn inline_multi_file_validates_constant() -> Result<(), Box<dyn std::error::Error>> {
1404        // AC2: Inlining validates that the symbol's value is constant
1405        let (_dir, index, paths) =
1406            setup_index(vec![("a.pl", "my $x = get_value();\nprint $x;\n")])?;
1407        let refactor = WorkspaceRefactor::new(index);
1408
1409        // Should succeed but with warnings for function calls
1410        let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1411        assert!(!result.file_edits.is_empty());
1412        // AC2: Warning detection validates that initializer contains function calls
1413        assert!(!result.warnings.is_empty(), "Should have warning about function call");
1414        Ok(())
1415    }
1416
1417    // AC3: Test scope respect and side effect avoidance
1418    #[test]
1419    fn inline_multi_file_respects_scope() -> Result<(), Box<dyn std::error::Error>> {
1420        // AC3: Cross-file inlining respects variable scope
1421        let (_dir, index, paths) = setup_index(vec![
1422            ("a.pl", "package A;\nmy $pkg_var = 10;\nprint $pkg_var;\n"),
1423            ("b.pl", "package B;\nmy $pkg_var = 20;\nprint $pkg_var;\n"),
1424        ])?;
1425        let refactor = WorkspaceRefactor::new(index);
1426
1427        // Should only inline in the correct package scope
1428        let result = refactor.inline_variable("$pkg_var", &paths[0], (0, 0))?;
1429        assert!(!result.file_edits.is_empty());
1430        Ok(())
1431    }
1432
1433    // AC4: Test variable type support (scalar, array, hash)
1434    #[test]
1435    fn inline_multi_file_supports_all_types() -> Result<(), Box<dyn std::error::Error>> {
1436        // AC4: Operation handles variable inlining ($var, @array, %hash)
1437        let (_dir, index, paths) = setup_index(vec![("scalar.pl", "my $x = 42;\nprint $x;\n")])?;
1438        let refactor = WorkspaceRefactor::new(index);
1439
1440        // Test scalar inlining
1441        let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1442        assert!(!result.file_edits.is_empty());
1443
1444        Ok(())
1445    }
1446
1447    // AC7: Test occurrence reporting
1448    #[test]
1449    fn inline_multi_file_reports_occurrences() -> Result<(), Box<dyn std::error::Error>> {
1450        // AC7: Operation reports total occurrences inlined
1451        let (_dir, index, paths) = setup_index(vec![
1452            ("a.pl", "my $x = 42;\nprint $x;\nprint $x;\nprint $x;\n"),
1453            ("b.pl", "print $x;\nprint $x;\n"),
1454        ])?;
1455        let refactor = WorkspaceRefactor::new(index);
1456        let result = refactor.inline_variable_all("$x", &paths[0], (0, 0))?;
1457
1458        // Check description mentions occurrence count or workspace
1459        assert!(
1460            result.description.contains("occurrence") || result.description.contains("workspace")
1461        );
1462        Ok(())
1463    }
1464}