Skip to main content

codelens_engine/
move_symbol.rs

1use crate::project::ProjectRoot;
2use crate::rename::{RenameEdit, apply_edits, find_all_word_matches};
3use crate::symbols::{find_symbol, find_symbol_range};
4use anyhow::{Result, bail};
5use serde::Serialize;
6use std::fs;
7
8#[derive(Debug, Clone, Serialize)]
9pub struct MoveResult {
10    pub success: bool,
11    pub message: String,
12    pub source_file: String,
13    pub target_file: String,
14    pub symbol_name: String,
15    pub import_updates: usize,
16    pub edits: Vec<MoveEdit>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct MoveEdit {
21    pub file_path: String,
22    pub action: MoveAction,
23    pub content: String,
24}
25
26#[derive(Debug, Clone, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MoveAction {
29    RemoveFromSource,
30    AddToTarget,
31    UpdateImport,
32}
33
34/// Move a symbol from one file to another, updating imports across the project.
35pub fn move_symbol(
36    project: &ProjectRoot,
37    file_path: &str,
38    symbol_name: &str,
39    name_path: Option<&str>,
40    target_file: &str,
41    dry_run: bool,
42) -> Result<MoveResult> {
43    if file_path == target_file {
44        bail!("Source and target files are the same");
45    }
46
47    // 1. Find the symbol
48    let symbols = find_symbol(project, symbol_name, Some(file_path), true, true, 1)?;
49    let _sym = symbols
50        .first()
51        .ok_or_else(|| anyhow::anyhow!("Symbol '{}' not found in '{}'", symbol_name, file_path))?;
52
53    // 2. Extract the full symbol text
54    let resolved_source = project.resolve(file_path)?;
55    let source_content = fs::read_to_string(&resolved_source)?;
56    let (start_byte, end_byte) = find_symbol_range(project, file_path, symbol_name, name_path)?;
57    let symbol_text = source_content[start_byte..end_byte].to_string();
58
59    // 3. Determine the line range of the symbol for removal
60    let start_line = source_content[..start_byte].lines().count();
61    let end_line = source_content[..end_byte].lines().count();
62
63    // 4. Build edits
64    let mut edits = Vec::new();
65
66    // Edit 1: Remove from source
67    edits.push(MoveEdit {
68        file_path: file_path.to_string(),
69        action: MoveAction::RemoveFromSource,
70        content: symbol_text.clone(),
71    });
72
73    // Edit 2: Add to target
74    edits.push(MoveEdit {
75        file_path: target_file.to_string(),
76        action: MoveAction::AddToTarget,
77        content: symbol_text.clone(),
78    });
79
80    // 5. Find import references to update
81    let matches = find_all_word_matches(project, symbol_name)?;
82    let ext = std::path::Path::new(file_path)
83        .extension()
84        .and_then(|e| e.to_str())
85        .unwrap_or("");
86
87    let source_module = file_path_to_module(file_path, ext);
88    let target_module = file_path_to_module(target_file, ext);
89
90    let mut import_edits: Vec<RenameEdit> = Vec::new();
91
92    for (ref_file, line, _col) in &matches {
93        if ref_file == file_path || ref_file == target_file {
94            continue;
95        }
96        let ref_resolved = match project.resolve(ref_file) {
97            Ok(p) => p,
98            Err(_) => continue,
99        };
100        let ref_content = match fs::read_to_string(&ref_resolved) {
101            Ok(c) => c,
102            Err(_) => continue,
103        };
104        let ref_lines: Vec<&str> = ref_content.lines().collect();
105        if *line == 0 || *line > ref_lines.len() {
106            continue;
107        }
108        let line_text = ref_lines[*line - 1];
109
110        // Check if this line is an import/from statement referencing the source module
111        if is_import_line(line_text, &source_module, ext) {
112            let new_line = line_text.replace(&source_module, &target_module);
113            if new_line != line_text {
114                import_edits.push(RenameEdit {
115                    file_path: ref_file.clone(),
116                    line: *line,
117                    column: 1,
118                    old_text: line_text.to_string(),
119                    new_text: new_line,
120                });
121
122                edits.push(MoveEdit {
123                    file_path: ref_file.clone(),
124                    action: MoveAction::UpdateImport,
125                    content: format!("{} → {}", source_module, target_module),
126                });
127            }
128        }
129    }
130
131    let import_updates = edits
132        .iter()
133        .filter(|e| matches!(e.action, MoveAction::UpdateImport))
134        .count();
135
136    let result = MoveResult {
137        success: true,
138        message: format!(
139            "Moved '{}' from '{}' to '{}', updated {} import(s)",
140            symbol_name, file_path, target_file, import_updates
141        ),
142        source_file: file_path.to_string(),
143        target_file: target_file.to_string(),
144        symbol_name: symbol_name.to_string(),
145        import_updates,
146        edits,
147    };
148
149    if !dry_run {
150        // Remove symbol from source file
151        let source_lines: Vec<String> = source_content.lines().map(String::from).collect();
152        let start_idx = if start_line > 0 { start_line - 1 } else { 0 };
153        let end_idx = end_line.min(source_lines.len());
154        let mut new_lines: Vec<String> = Vec::new();
155        for (i, line) in source_lines.iter().enumerate() {
156            if i < start_idx || i >= end_idx {
157                new_lines.push(line.clone());
158            }
159        }
160        // Remove trailing blank line if the symbol was followed by one
161        if start_idx > 0
162            && start_idx < new_lines.len()
163            && new_lines[start_idx].trim().is_empty()
164            && (start_idx == 0 || new_lines[start_idx - 1].trim().is_empty())
165        {
166            new_lines.remove(start_idx);
167        }
168        let mut new_source = new_lines.join("\n");
169        if source_content.ends_with('\n') {
170            new_source.push('\n');
171        }
172        fs::write(&resolved_source, &new_source)?;
173
174        // Add symbol to target file
175        let resolved_target = project.resolve(target_file)?;
176        let mut target_content = if resolved_target.exists() {
177            fs::read_to_string(&resolved_target)?
178        } else {
179            String::new()
180        };
181
182        if !target_content.is_empty() && !target_content.ends_with('\n') {
183            target_content.push('\n');
184        }
185        if !target_content.is_empty() {
186            target_content.push('\n');
187        }
188        target_content.push_str(&symbol_text);
189        target_content.push('\n');
190
191        if let Some(parent) = resolved_target.parent() {
192            fs::create_dir_all(parent)?;
193        }
194        fs::write(&resolved_target, &target_content)?;
195
196        // Update imports across the project
197        if !import_edits.is_empty() {
198            apply_edits(project, &import_edits)?;
199        }
200    }
201
202    Ok(result)
203}
204
205/// Convert a file path to a module path based on language conventions.
206fn file_path_to_module(path: &str, ext: &str) -> String {
207    let without_ext = path.strip_suffix(&format!(".{}", ext)).unwrap_or(path);
208
209    match ext {
210        "py" => without_ext.replace(['/', '\\'], "."),
211        "js" | "ts" | "tsx" | "jsx" => {
212            let clean = without_ext.strip_suffix("/index").unwrap_or(without_ext);
213            format!("./{}", clean)
214        }
215        "go" => {
216            // Go uses directory-based packages
217            std::path::Path::new(without_ext)
218                .parent()
219                .map(|p| p.to_string_lossy().to_string())
220                .unwrap_or_else(|| ".".to_string())
221        }
222        "java" | "kt" | "scala" => without_ext.replace(['/', '\\'], "."),
223        _ => without_ext.replace(['/', '\\'], "."),
224    }
225}
226
227/// Check if a line is an import statement referencing the given module.
228fn is_import_line(line: &str, module: &str, ext: &str) -> bool {
229    let trimmed = line.trim();
230    match ext {
231        "py" => {
232            (trimmed.starts_with("from ") || trimmed.starts_with("import "))
233                && trimmed.contains(module)
234        }
235        "js" | "ts" | "tsx" | "jsx" => {
236            (trimmed.starts_with("import ") || trimmed.contains("require("))
237                && trimmed.contains(module)
238        }
239        "go" => trimmed.contains(module) && trimmed.contains('"'),
240        "java" | "kt" | "scala" => trimmed.starts_with("import ") && trimmed.contains(module),
241        "rs" => trimmed.starts_with("use ") && trimmed.contains(module),
242        _ => trimmed.starts_with("import ") && trimmed.contains(module),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::ProjectRoot;
250    use std::fs;
251
252    fn make_fixture() -> (std::path::PathBuf, ProjectRoot) {
253        let dir = std::env::temp_dir().join(format!(
254            "codelens-move-fixture-{}",
255            std::time::SystemTime::now()
256                .duration_since(std::time::UNIX_EPOCH)
257                .unwrap()
258                .as_nanos()
259        ));
260        fs::create_dir_all(&dir).unwrap();
261        let project = ProjectRoot::new(dir.clone()).unwrap();
262        (dir, project)
263    }
264
265    #[test]
266    fn test_file_path_to_module_python() {
267        assert_eq!(
268            file_path_to_module("utils/helpers.py", "py"),
269            "utils.helpers"
270        );
271    }
272
273    #[test]
274    fn test_file_path_to_module_js() {
275        assert_eq!(
276            file_path_to_module("utils/helpers.js", "js"),
277            "./utils/helpers"
278        );
279    }
280
281    #[test]
282    fn test_is_import_line_python() {
283        assert!(is_import_line(
284            "from utils.helpers import foo",
285            "utils.helpers",
286            "py"
287        ));
288        assert!(!is_import_line("x = helpers.foo()", "utils.helpers", "py"));
289    }
290
291    #[test]
292    fn test_is_import_line_js() {
293        assert!(is_import_line(
294            "import { foo } from './utils/helpers';",
295            "./utils/helpers",
296            "js"
297        ));
298    }
299
300    #[test]
301    fn test_same_file_error() {
302        let (_dir, project) = make_fixture();
303        let result = move_symbol(&project, "a.py", "foo", None, "a.py", true);
304        assert!(result.is_err());
305    }
306
307    #[test]
308    fn test_move_dry_run() {
309        let (dir, project) = make_fixture();
310
311        let source = "def foo():\n    return 42\n\ndef bar():\n    return foo()\n";
312        fs::write(dir.join("source.py"), source).unwrap();
313        fs::write(dir.join("target.py"), "# target\n").unwrap();
314
315        let result = move_symbol(&project, "source.py", "foo", None, "target.py", true).unwrap();
316        assert!(result.success);
317        assert_eq!(result.symbol_name, "foo");
318
319        // Dry run: files unchanged
320        let after = fs::read_to_string(dir.join("source.py")).unwrap();
321        assert_eq!(after, source);
322
323        fs::remove_dir_all(&dir).ok();
324    }
325}