1use std::path::{Path, PathBuf};
13
14use crate::actions;
15use crate::{PlannedEdit, RefactoringContext, RefactoringPlan};
16
17pub struct MoveOutcome {
20 pub plan: RefactoringPlan,
21 pub symbol: String,
22 pub from_file: String,
23 pub to_file: String,
24 pub definition_moved: bool,
25 pub import_sites_updated: usize,
26 pub import_sites_skipped: usize,
27 pub reexport_added: bool,
28}
29
30pub async fn plan_move(
36 ctx: &RefactoringContext,
37 from_rel_path: &str,
38 to_rel_path: &str,
39 symbol_name: &str,
40 reexport: bool,
41) -> Result<MoveOutcome, String> {
42 let from_abs = ctx.root.join(from_rel_path);
43 let to_abs = ctx.root.join(to_rel_path);
44
45 if from_abs == to_abs {
46 return Err(format!(
47 "source and destination are the same file: {}",
48 from_rel_path
49 ));
50 }
51
52 let from_content = std::fs::read_to_string(&from_abs)
53 .map_err(|e| format!("Error reading {}: {}", from_rel_path, e))?;
54
55 let mut loc = actions::locate_symbol(ctx, &from_abs, &from_content, symbol_name)
57 .ok_or_else(|| format!("Symbol '{}' not found in {}", symbol_name, from_rel_path))?;
58
59 let (extended_start, decoration_warning) =
63 actions::decoration_extended_start(&from_abs, &from_content, &loc);
64 loc.start_byte = extended_start;
65
66 let line_start = from_content[..loc.start_byte]
68 .rfind('\n')
69 .map(|i| i + 1)
70 .unwrap_or(0);
71 let mut def_end = loc.end_byte;
72 if def_end < from_content.len() && from_content.as_bytes()[def_end] == b'\n' {
73 def_end += 1;
74 }
75 let definition_text = from_content[line_start..def_end].to_string();
76
77 let mut edits: Vec<PlannedEdit> = Vec::new();
78 let mut warnings: Vec<String> = Vec::new();
79 if let Some(w) = decoration_warning {
80 warnings.push(w);
81 }
82
83 let dest_original = std::fs::read_to_string(&to_abs).unwrap_or_default();
85 let dest_new = ctx.editor.append_to_file(&dest_original, &definition_text);
86 edits.push(PlannedEdit {
87 file: to_abs.clone(),
88 original: dest_original.clone(),
89 new_content: dest_new,
90 description: format!("append {}", symbol_name),
91 });
92
93 let mut src_new = ctx.editor.delete_symbol(&from_content, &loc);
95 let mut reexport_added = false;
96 if reexport {
97 if let Some(stub) = build_reexport(&from_abs, &to_abs, symbol_name) {
98 src_new = ctx.editor.append_to_file(&src_new, &stub);
100 reexport_added = true;
101 } else {
102 warnings.push(format!(
103 "could not derive re-export for {} (unsupported language)",
104 from_rel_path
105 ));
106 }
107 }
108 edits.push(PlannedEdit {
109 file: from_abs.clone(),
110 original: from_content.clone(),
111 new_content: src_new,
112 description: format!("remove {}", symbol_name),
113 });
114
115 let mut import_sites_updated = 0usize;
117 let mut import_sites_skipped = 0usize;
118
119 if let Some(ref idx) = ctx.index {
120 let importers = idx
121 .find_symbol_importers_with_module(symbol_name)
122 .await
123 .unwrap_or_default();
124
125 use std::collections::HashMap;
127 let mut by_file: HashMap<String, Vec<(usize, Option<String>)>> = HashMap::new();
128 for (file, _name, _alias, line, module) in importers {
129 if file == from_rel_path {
131 continue;
132 }
133 by_file.entry(file).or_default().push((line, module));
134 }
135
136 for (rel_path, lines_modules) in by_file {
137 let abs_path = ctx.root.join(&rel_path);
138 let original = match std::fs::read_to_string(&abs_path) {
139 Ok(c) => c,
140 Err(_) => {
141 warnings.push(format!("could not read importer file: {}", rel_path));
142 import_sites_skipped += lines_modules.len();
143 continue;
144 }
145 };
146
147 let mut current = original.clone();
148 let mut file_changed = false;
149 for (line_no, old_module) in lines_modules {
150 let Some(old_module) = old_module else {
151 warnings.push(format!(
152 "{}:{}: import has no module path; skipped",
153 rel_path, line_no
154 ));
155 import_sites_skipped += 1;
156 continue;
157 };
158 let Some(new_module) = derive_new_module(&abs_path, &to_abs, &old_module) else {
159 warnings.push(format!(
160 "{}:{}: cannot derive new module path for destination {}; skipped",
161 rel_path, line_no, to_rel_path
162 ));
163 import_sites_skipped += 1;
164 continue;
165 };
166 match replace_module_on_line(¤t, line_no, &old_module, &new_module) {
167 Some(updated) => {
168 current = updated;
169 file_changed = true;
170 import_sites_updated += 1;
171 }
172 None => {
173 warnings.push(format!(
174 "{}:{}: could not locate module string '{}' on line; skipped",
175 rel_path, line_no, old_module
176 ));
177 import_sites_skipped += 1;
178 }
179 }
180 }
181
182 if file_changed {
183 edits.push(PlannedEdit {
184 file: abs_path,
185 original,
186 new_content: current,
187 description: format!("rewrite imports of {}", symbol_name),
188 });
189 }
190 }
191 } else {
192 warnings.push(
193 "Index not available; moved definition only (import sites not rewritten)".to_string(),
194 );
195 }
196
197 Ok(MoveOutcome {
198 plan: RefactoringPlan {
199 operation: "move".to_string(),
200 edits,
201 warnings,
202 },
203 symbol: symbol_name.to_string(),
204 from_file: from_rel_path.to_string(),
205 to_file: to_rel_path.to_string(),
206 definition_moved: true,
207 import_sites_updated,
208 import_sites_skipped,
209 reexport_added,
210 })
211}
212
213fn replace_module_on_line(
216 content: &str,
217 line_no: usize,
218 old_module: &str,
219 new_module: &str,
220) -> Option<String> {
221 if old_module.is_empty() {
222 return None;
223 }
224 let line_start = byte_offset_for_line(content, line_no);
225 let line_end = content[line_start..]
226 .find('\n')
227 .map(|n| line_start + n)
228 .unwrap_or(content.len());
229 let line = &content[line_start..line_end];
230 if !line.contains(old_module) {
231 return None;
232 }
233 let new_line = line.replacen(old_module, new_module, 1);
234 if new_line == line {
235 return None;
236 }
237 let mut out = String::with_capacity(content.len() + new_module.len());
238 out.push_str(&content[..line_start]);
239 out.push_str(&new_line);
240 out.push_str(&content[line_end..]);
241 Some(out)
242}
243
244fn byte_offset_for_line(content: &str, line: usize) -> usize {
245 if line <= 1 {
246 return 0;
247 }
248 let mut seen = 0usize;
249 for (i, b) in content.bytes().enumerate() {
250 if b == b'\n' {
251 seen += 1;
252 if seen == line - 1 {
253 return i + 1;
254 }
255 }
256 }
257 content.len()
258}
259
260fn derive_new_module(importer_path: &Path, dest_path: &Path, old_module: &str) -> Option<String> {
273 let ext = dest_path.extension().and_then(|e| e.to_str())?;
274 match ext {
275 "py" => derive_python_module(dest_path, old_module),
276 "go" => Some(go_import_path(dest_path)?),
277 "js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" => derive_js_relative(importer_path, dest_path),
278 _ => None,
279 }
280}
281
282fn derive_python_module(dest_path: &Path, old_module: &str) -> Option<String> {
283 if old_module.starts_with('.') {
286 return None;
287 }
288 let stem = dest_path.file_stem()?.to_str()?;
291 let mut parts: Vec<String> = Vec::new();
292 if stem != "__init__" {
293 parts.push(stem.to_string());
294 }
295 let mut dir = dest_path.parent()?;
296 while dir.join("__init__.py").exists() {
297 let name = dir.file_name()?.to_str()?.to_string();
298 parts.push(name);
299 match dir.parent() {
300 Some(p) => dir = p,
301 None => break,
302 }
303 }
304 if parts.is_empty() {
305 return None;
306 }
307 parts.reverse();
308 Some(parts.join("."))
309}
310
311fn go_import_path(dest_path: &Path) -> Option<String> {
312 let dir = dest_path.parent()?;
316 let s = dir.to_str()?;
317 Some(s.to_string())
318}
319
320fn derive_js_relative(importer_path: &Path, dest_path: &Path) -> Option<String> {
321 let importer_dir = importer_path.parent()?;
324 let rel = pathdiff(dest_path, importer_dir)?;
325 let rel_str = rel.to_str()?;
326 let without_ext = match rel_str.rsplit_once('.') {
327 Some((stem, "js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx")) => stem,
328 _ => rel_str,
329 };
330 let with_prefix = if without_ext.starts_with('.') || without_ext.starts_with('/') {
331 without_ext.to_string()
332 } else {
333 format!("./{}", without_ext)
334 };
335 Some(with_prefix)
336}
337
338fn pathdiff(target: &Path, base: &Path) -> Option<PathBuf> {
340 use std::path::Component;
341 let target: Vec<Component> = target.components().collect();
342 let base: Vec<Component> = base.components().collect();
343 let common = target
344 .iter()
345 .zip(base.iter())
346 .take_while(|(a, b)| a == b)
347 .count();
348 let ups = base.len() - common;
349 let mut out = PathBuf::new();
350 if ups == 0 {
351 out.push(".");
352 }
353 for _ in 0..ups {
354 out.push("..");
355 }
356 for c in &target[common..] {
357 out.push(c.as_os_str());
358 }
359 Some(out)
360}
361
362fn build_reexport(from_path: &Path, to_path: &Path, symbol: &str) -> Option<String> {
365 let ext = from_path.extension().and_then(|e| e.to_str())?;
366 match ext {
367 "py" => {
368 let module = derive_python_module(to_path, symbol)?;
369 Some(format!("from {} import {}\n", module, symbol))
370 }
371 _ => None,
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn replace_module_on_line_basic() {
384 let content = "from old.path import Thing\nother\n";
385 let out = replace_module_on_line(content, 1, "old.path", "new.path").unwrap();
386 assert_eq!(out, "from new.path import Thing\nother\n");
387 }
388
389 #[test]
390 fn replace_module_on_line_missing_returns_none() {
391 let content = "from x import Thing\n";
392 assert!(replace_module_on_line(content, 1, "absent", "y").is_none());
393 }
394
395 #[test]
396 fn pathdiff_sibling() {
397 let target = Path::new("a/b/c.ts");
398 let base = Path::new("a/b");
399 assert_eq!(pathdiff(target, base).unwrap(), PathBuf::from("./c.ts"));
400 }
401
402 #[tokio::test]
403 async fn plan_move_includes_python_decorator_and_comment() {
404 if normalize_languages::parsers::parser_for("python").is_none() {
405 eprintln!("skipping: python grammar not available");
406 return;
407 }
408 let dir = tempfile::tempdir().unwrap();
409 let from_path = dir.path().join("src.py");
410 let to_path = dir.path().join("dest.py");
411 let from_content = "\
412import os
413
414# Important note about my_func.
415@decorator
416def my_func():
417 return 1
418
419def other():
420 return 2
421";
422 std::fs::write(&from_path, from_content).unwrap();
423 std::fs::write(&to_path, "").unwrap();
424
425 let ctx = RefactoringContext {
426 root: dir.path().to_path_buf(),
427 editor: normalize_edit::Editor::new(),
428 index: None,
429 loader: normalize_languages::GrammarLoader::new(),
430 };
431
432 let outcome = plan_move(&ctx, "src.py", "dest.py", "my_func", false)
433 .await
434 .expect("plan_move");
435
436 let dest_edit = outcome
438 .plan
439 .edits
440 .iter()
441 .find(|e| e.file == to_path)
442 .expect("dest edit");
443 assert!(
444 dest_edit
445 .new_content
446 .contains("# Important note about my_func."),
447 "dest missing leading comment; got: {:?}",
448 dest_edit.new_content
449 );
450 assert!(
451 dest_edit.new_content.contains("@decorator"),
452 "dest missing decorator; got: {:?}",
453 dest_edit.new_content
454 );
455
456 let src_edit = outcome
458 .plan
459 .edits
460 .iter()
461 .find(|e| e.file == from_path)
462 .expect("src edit");
463 assert!(
464 !src_edit.new_content.contains("@decorator"),
465 "src still contains decorator; got: {:?}",
466 src_edit.new_content
467 );
468 assert!(
469 !src_edit
470 .new_content
471 .contains("# Important note about my_func."),
472 "src still contains leading comment; got: {:?}",
473 src_edit.new_content
474 );
475 assert!(src_edit.new_content.contains("def other():"));
477 }
478
479 #[test]
480 fn derive_js_relative_strips_ext() {
481 let importer = Path::new("src/app.ts");
482 let dest = Path::new("src/lib/foo.ts");
483 let out = derive_js_relative(importer, dest).unwrap();
484 assert_eq!(out, "./lib/foo");
485 }
486}