Skip to main content

normalize_refactor/
rename.rs

1//! Rename recipe: composable rename using semantic actions.
2//!
3//! Decomposes the monolithic `do_rename` into:
4//! 1. `locate_symbol` — find definition
5//! 2. `find_references` — gather callers + importers from index
6//! 3. `check_conflicts` — detect naming collisions
7//! 4. `plan_rename_in_file` — produce edits for each affected file
8
9use std::collections::HashMap;
10
11use crate::actions;
12use crate::{PlannedEdit, RefactoringContext, RefactoringPlan};
13
14/// Build a rename plan without touching the filesystem.
15///
16/// Returns a `RefactoringPlan` containing all edits needed, or an error
17/// if the target can't be resolved or conflicts are detected.
18pub async fn plan_rename(
19    ctx: &RefactoringContext,
20    def_rel_path: &str,
21    old_name: &str,
22    new_name: &str,
23    force: bool,
24) -> Result<RefactoringPlan, String> {
25    let def_rel_path = def_rel_path.to_string();
26    let def_abs_path = ctx.root.join(&def_rel_path);
27
28    let def_content = std::fs::read_to_string(&def_abs_path)
29        .map_err(|e| format!("Error reading {}: {}", def_rel_path, e))?;
30
31    // 1. Locate definition
32    let loc = actions::locate_symbol(ctx, &def_abs_path, &def_content, old_name)
33        .ok_or_else(|| format!("Symbol '{}' not found in {}", old_name, def_rel_path))?;
34
35    // 2. Find cross-file references
36    let refs = actions::find_references(ctx, old_name, &def_rel_path).await;
37
38    // 3. Check conflicts
39    if !force {
40        let conflicts =
41            actions::check_conflicts(ctx, &def_abs_path, &def_content, new_name, &refs.importers)
42                .await;
43        if !conflicts.is_empty() {
44            let detail = conflicts
45                .iter()
46                .map(|c| format!("  {}", c))
47                .collect::<Vec<_>>()
48                .join("\n");
49            return Err(format!(
50                "Rename '{}' → '{}' would cause conflicts (use --force to override):\n{}",
51                old_name, new_name, detail
52            ));
53        }
54    }
55
56    let mut edits: Vec<PlannedEdit> = vec![];
57    let mut warnings: Vec<String> = vec![];
58
59    // 4a. Rename in definition file
60    if let Some(edit) = actions::plan_rename_in_file(
61        ctx,
62        &def_abs_path,
63        &def_content,
64        &[loc.start_line],
65        old_name,
66        new_name,
67    ) {
68        edits.push(edit);
69    }
70
71    // 4b. Rename at call sites (grouped by file)
72    let mut callers_by_file: HashMap<String, Vec<usize>> = HashMap::new();
73    for caller in &refs.callers {
74        callers_by_file
75            .entry(caller.file.clone())
76            .or_default()
77            .push(caller.line);
78    }
79
80    // Track which files we've already produced edits for
81    let mut edited_files: std::collections::HashSet<String> = std::collections::HashSet::new();
82    edited_files.insert(def_rel_path.clone());
83
84    for (rel_path, lines) in &callers_by_file {
85        if rel_path == &def_rel_path {
86            // Definition file already handled; self-recursive calls are on the same lines
87            continue;
88        }
89        let abs_path = ctx.root.join(rel_path);
90        let content = match std::fs::read_to_string(&abs_path) {
91            Ok(c) => c,
92            Err(_) => {
93                warnings.push(format!("Could not read caller file: {}", rel_path));
94                continue;
95            }
96        };
97        if let Some(edit) =
98            actions::plan_rename_in_file(ctx, &abs_path, &content, lines, old_name, new_name)
99        {
100            edits.push(edit);
101            edited_files.insert(rel_path.clone());
102        }
103    }
104
105    // 4c. Rename in import statements (grouped by file)
106    let mut importers_by_file: HashMap<String, Vec<usize>> = HashMap::new();
107    for imp in &refs.importers {
108        importers_by_file
109            .entry(imp.file.clone())
110            .or_default()
111            .push(imp.line);
112    }
113
114    for (rel_path, lines) in &importers_by_file {
115        if edited_files.contains(rel_path) {
116            // File already has an edit — we need to apply import renames on top of
117            // the already-renamed content. Find the existing edit and update it.
118            if let Some(existing) = edits.iter_mut().find(|e| e.file == ctx.root.join(rel_path)) {
119                // Apply import renames on top of the already-modified content
120                let mut current = existing.new_content.clone();
121                let mut changed = false;
122                for &line_no in lines {
123                    if let Some(new_content) = ctx
124                        .editor
125                        .rename_identifier_in_line(&current, line_no, old_name, new_name)
126                    {
127                        current = new_content;
128                        changed = true;
129                    }
130                }
131                if changed {
132                    existing.new_content = current;
133                }
134                continue;
135            }
136        }
137        let abs_path = ctx.root.join(rel_path);
138        let content = 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                continue;
143            }
144        };
145        if let Some(edit) =
146            actions::plan_rename_in_file(ctx, &abs_path, &content, lines, old_name, new_name)
147        {
148            edits.push(edit);
149        }
150    }
151
152    // Add warning if no index was available
153    if ctx.index.is_none() && refs.callers.is_empty() && refs.importers.is_empty() {
154        warnings.push("Index not available; renamed definition only".to_string());
155    }
156
157    Ok(RefactoringPlan {
158        operation: "rename".to_string(),
159        edits,
160        warnings,
161    })
162}