Skip to main content

normalize_refactor/
move_item.rs

1//! Move recipe: relocate a symbol's definition to another file and rewrite imports.
2//!
3//! Steps:
4//! 1. Locate the symbol in the source file
5//! 2. Extract its definition text via the editor
6//! 3. Append the definition to the destination file
7//! 4. Delete the definition from the source file
8//! 5. Rewrite import statements in every file that imported it from the old module path
9//!    (best-effort: emits a warning and skips when the new path can't be derived)
10//! 6. Optionally leave a re-export in the source file (`--reexport`)
11
12use std::path::{Path, PathBuf};
13
14use crate::actions;
15use crate::{PlannedEdit, RefactoringContext, RefactoringPlan};
16
17/// Outcome details for a planned move (used by callers that want a richer report
18/// than `RefactoringPlan` alone).
19pub 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
30/// Build a move plan without touching the filesystem.
31///
32/// `from_rel_path` is the relative path to the source file containing the symbol.
33/// `to_rel_path` is the relative path to the destination file.
34/// `symbol_name` is the unqualified name of the symbol to move.
35pub 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    // 1. Locate the symbol in the source.
56    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    // Extend the start backward to include leading decorations (doc comments,
60    // attributes, decorators) identified via tree-sitter `node.kind()`. Falls
61    // back to `loc.start_byte` for languages without a grammar.
62    let (extended_start, decoration_warning) =
63        actions::decoration_extended_start(&from_abs, &from_content, &loc);
64    loc.start_byte = extended_start;
65
66    // 2. Extract definition text (whole-line span of the symbol).
67    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    // 3. Append the definition to the destination file (creating it if absent).
84    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    // 4. Remove the definition from the source file (and optionally leave a re-export).
94    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            // Append the re-export at the end of the source file.
99            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    // 5. Rewrite import statements in every importer.
116    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        // Group importers by file so each file produces at most one PlannedEdit.
126        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            // Skip: the source file itself (we already edited it).
130            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(&current, 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
213/// Replace `old_module` with `new_module` on a specific 1-based line of `content`.
214/// Returns `None` if the substring is not present on that line.
215fn 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
260/// Derive the new module path string for an `import` in `importer_path` that previously
261/// referenced `old_module`. Returns `None` when the language can't be confidently handled.
262///
263/// Strategy per language:
264/// - **Python**: dotted module path from project root (`pkg/sub/mod.py` → `pkg.sub.mod`).
265///   We only switch when both the old and new module strings look dotted; otherwise we
266///   leave the user a warning so they can fix relative imports by hand.
267/// - **Go**: directory-based import path. We replace the trailing module segment chain.
268/// - **Rust / JavaScript / TypeScript / others**: skipped (returns `None`). Rust uses
269///   `crate::`/`super::` paths that aren't a 1:1 function of file path; JS/TS use
270///   relative `./foo` paths whose form depends on the importer's location and the
271///   project's module resolution. Honest "I don't know" beats wrong output.
272fn 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    // Only handle dotted (absolute) module strings — relative imports (".foo") need
284    // knowledge of the importer's package layout we don't have here.
285    if old_module.starts_with('.') {
286        return None;
287    }
288    // Walk up from the destination collecting components until we hit a directory
289    // that does NOT contain an `__init__.py` (i.e. the package root sibling).
290    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    // Go imports the directory, not the file. Best we can do without parsing
313    // go.mod is the directory components — caller still substitutes the previous
314    // import string, so we only need a stable form.
315    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    // Compute a relative path from the importer's directory to the destination,
322    // stripping the file extension (the standard JS/TS convention).
323    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
338/// Minimal `pathdiff::diff_paths` reimplementation (no external dep).
339fn 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
362/// Build a re-export stub left at the source location after a move.
363/// Returns `None` for languages where we don't have a defensible default.
364fn 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        // Rust re-exports require knowing the canonical module path of the destination,
372        // which is not a function of file path alone (mod tree may differ from FS layout).
373        // Be honest: don't fabricate a `pub use`.
374        _ => 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        // The destination file edit should contain the leading comment and decorator.
437        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        // The source file edit should have removed the comment and decorator.
457        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        // `other` should remain.
476        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}