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