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
34pub 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 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 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 let start_line = source_content[..start_byte].lines().count();
61 let end_line = source_content[..end_byte].lines().count();
62
63 let mut edits = Vec::new();
65
66 edits.push(MoveEdit {
68 file_path: file_path.to_string(),
69 action: MoveAction::RemoveFromSource,
70 content: symbol_text.clone(),
71 });
72
73 edits.push(MoveEdit {
75 file_path: target_file.to_string(),
76 action: MoveAction::AddToTarget,
77 content: symbol_text.clone(),
78 });
79
80 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 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 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 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 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 if !import_edits.is_empty() {
198 apply_edits(project, &import_edits)?;
199 }
200 }
201
202 Ok(result)
203}
204
205fn 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 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
227fn 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 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}