Skip to main content

perl_module/rename/
mod.rs

1//! Deterministic module-import rename edit planning.
2//!
3//! Computes line edits for Perl module file-rename workflows.
4
5use crate::import_match::line_references_module_import;
6use crate::token::{module_variant_pairs, replace_module_token};
7
8/// A full-line replacement edit for a module rename.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ModuleLineEdit {
11    /// Zero-based source line index.
12    pub line: usize,
13    /// Start column (always `0` for full-line replacement).
14    pub start_character: usize,
15    /// End column of the original line in bytes.
16    pub end_character: usize,
17    /// Replacement text for the full line.
18    pub new_text: String,
19}
20
21/// Plan full-line edits needed to update module imports after file rename.
22///
23/// Supported import forms:
24/// - `use Module::Name;`
25/// - `require Module::Name;`
26/// - `use parent 'Module::Name';`
27/// - `use parent "Module::Name";`
28/// - `use parent qw(Module::Name Other);`
29/// - `use base 'Module::Name';`
30/// - `use base "Module::Name";`
31/// - `use base qw(Module::Name Other);`
32/// - `extends 'Module::Name';`
33/// - `extends qw(Module::Name Other::Parent);`
34/// - `with 'Module::Role';`
35/// - `with qw(Module::Role Other::Role);`
36///
37/// Also rewrites:
38/// - Package declarations: `package Module::Name;` → `package NewName;`
39/// - Qualified function calls: `Module::Name::func()` → `NewName::func()`
40/// - Static method calls: `Module::Name->method()` → `NewName->method()`
41/// - `@ISA` array assignments
42///
43/// Legacy package separators (`Foo'Bar`) are also handled.
44#[must_use]
45pub fn plan_module_rename_edits(
46    source: &str,
47    old_module: &str,
48    new_module: &str,
49) -> Vec<ModuleLineEdit> {
50    if source.is_empty()
51        || old_module.is_empty()
52        || new_module.is_empty()
53        || old_module == new_module
54    {
55        return Vec::new();
56    }
57
58    let variants = module_variant_pairs(old_module, new_module);
59    let mut edits = Vec::new();
60
61    for (line_idx, line) in source.lines().enumerate() {
62        let mut rewritten: Option<String> = None;
63
64        for (old_variant, new_variant) in &variants {
65            // Check import forms (use/require/use parent/use base)
66            {
67                let current_line = rewritten.as_deref().unwrap_or(line);
68                if line_references_module_import(current_line, old_variant) {
69                    let (candidate, changed) =
70                        replace_module_token(current_line, old_variant, new_variant);
71                    if changed {
72                        rewritten = Some(candidate);
73                    }
74                }
75            }
76
77            // Check Moose/Moo inheritance/role composition forms.
78            {
79                let current_line = rewritten.as_deref().unwrap_or(line);
80                if line_references_moose_moo_dsl(current_line, old_variant) {
81                    let (candidate, changed) =
82                        replace_module_token(current_line, old_variant, new_variant);
83                    if changed {
84                        rewritten = Some(candidate);
85                    }
86                }
87            }
88
89            // Check @ISA assignments
90            {
91                let current_line = rewritten.as_deref().unwrap_or(line);
92                if line_references_isa_assignment(current_line, old_variant) {
93                    let (candidate, changed) =
94                        replace_module_token(current_line, old_variant, new_variant);
95                    if changed {
96                        rewritten = Some(candidate);
97                    }
98                }
99            }
100
101            // Check qualified function calls
102            {
103                let current_line = rewritten.as_deref().unwrap_or(line);
104                if line_references_qualified_call(current_line, old_variant) {
105                    let candidate =
106                        replace_module_name_prefix(current_line, old_variant, new_variant);
107                    if candidate != current_line {
108                        rewritten = Some(candidate);
109                    }
110                }
111            }
112
113            // Check package declarations
114            {
115                let current_line = rewritten.as_deref().unwrap_or(line);
116                if line_references_package_declaration(current_line, old_variant) {
117                    let (candidate, changed) =
118                        replace_module_token(current_line, old_variant, new_variant);
119                    if changed {
120                        rewritten = Some(candidate);
121                    }
122                }
123            }
124        }
125
126        if let Some(new_text) = rewritten {
127            edits.push(ModuleLineEdit {
128                line: line_idx,
129                start_character: 0,
130                end_character: line.len(),
131                new_text,
132            });
133        }
134    }
135
136    edits
137}
138
139fn line_references_moose_moo_dsl(line: &str, module_name: &str) -> bool {
140    if line.is_empty() || module_name.is_empty() {
141        return false;
142    }
143    let trimmed = line.trim_start();
144    let is_extends =
145        trimmed == "extends" || trimmed.starts_with("extends ") || trimmed.starts_with("extends(");
146    let is_with = trimmed == "with" || trimmed.starts_with("with ") || trimmed.starts_with("with(");
147    if !is_extends && !is_with {
148        return false;
149    }
150    crate::token::contains_module_token(line, module_name)
151}
152
153/// Return `true` when `line` contains an `@ISA` assignment that references
154/// `module_name` as a standalone token.
155#[must_use]
156pub fn line_references_isa_assignment(line: &str, module_name: &str) -> bool {
157    if line.is_empty() || module_name.is_empty() {
158        return false;
159    }
160    if !line.contains("@ISA") {
161        return false;
162    }
163    crate::token::contains_module_token(line, module_name)
164}
165
166/// Return `true` when `line` contains a qualified call that uses `module_name`
167/// as a namespace prefix.
168#[must_use]
169pub fn line_references_qualified_call(line: &str, module_name: &str) -> bool {
170    if line.is_empty() || module_name.is_empty() {
171        return false;
172    }
173    let trimmed = line.trim_start();
174    if trimmed.starts_with("package ")
175        || trimmed.starts_with("use ")
176        || trimmed.starts_with("require ")
177        || trimmed.starts_with("no ")
178    {
179        return false;
180    }
181    for separator in ["::", "'"] {
182        let needle = format!("{module_name}{separator}");
183        let needle_bytes = needle.as_bytes();
184        let line_bytes = line.as_bytes();
185        let needle_len = needle_bytes.len();
186
187        if line_bytes.len() < needle_len {
188            continue;
189        }
190
191        let mut start = 0usize;
192        while start + needle_len <= line_bytes.len() {
193            let Some(rel) = line[start..].find(needle.as_str()) else {
194                break;
195            };
196            let abs = start + rel;
197            let after = abs + needle_len;
198
199            let before_ok = abs == 0 || {
200                let ch = line_bytes[abs - 1] as char;
201                !ch.is_alphanumeric() && ch != '_' && ch != ':'
202            };
203
204            let after_ok = after < line_bytes.len() && {
205                let ch = line_bytes[after] as char;
206                ch.is_alphabetic() || ch == '_'
207            };
208
209            if before_ok && after_ok && !index_is_in_quote_or_comment(line, abs) {
210                return true;
211            }
212            start = abs + 1;
213        }
214    }
215
216    false
217}
218
219/// Return `true` when `line` contains a package declaration referencing
220/// `module_name` as a standalone token.
221#[must_use]
222pub fn line_references_package_declaration(line: &str, module_name: &str) -> bool {
223    if line.is_empty() || module_name.is_empty() {
224        return false;
225    }
226    if !line.trim_start().starts_with("package ") {
227        return false;
228    }
229    crate::token::contains_module_token(line, module_name)
230}
231
232/// Replace `old_module::` namespace prefixes in `line` with `new_module::`.
233#[must_use]
234pub fn replace_module_name_prefix(line: &str, old_module: &str, new_module: &str) -> String {
235    if old_module.is_empty() || new_module.is_empty() || line.is_empty() {
236        return line.to_string();
237    }
238    let trimmed = line.trim_start();
239    if trimmed.starts_with("package ")
240        || trimmed.starts_with("use ")
241        || trimmed.starts_with("require ")
242        || trimmed.starts_with("no ")
243    {
244        return line.to_string();
245    }
246
247    let mut out = line.to_string();
248
249    for separator in ["::", "'"] {
250        let needle = format!("{old_module}{separator}");
251        let replacement = format!("{new_module}{separator}");
252        let needle_bytes = needle.as_bytes();
253        let needle_len = needle_bytes.len();
254        let line_bytes = out.as_bytes();
255
256        if line_bytes.len() < needle_len {
257            continue;
258        }
259
260        let mut replaced = String::with_capacity(out.len());
261        let mut cursor = 0usize;
262
263        while cursor + needle_len <= line_bytes.len() {
264            let Some(rel) = out[cursor..].find(needle.as_str()) else {
265                break;
266            };
267            let abs = cursor + rel;
268            let after = abs + needle_len;
269
270            let before_ok = abs == 0 || {
271                let ch = line_bytes[abs - 1] as char;
272                !ch.is_alphanumeric() && ch != '_' && ch != ':'
273            };
274
275            let after_ok = after < line_bytes.len() && {
276                let ch = line_bytes[after] as char;
277                ch.is_alphabetic() || ch == '_'
278            };
279
280            if before_ok && after_ok && !index_is_in_quote_or_comment(&out, abs) {
281                replaced.push_str(&out[cursor..abs]);
282                replaced.push_str(&replacement);
283                cursor = after;
284            } else {
285                replaced.push_str(&out[cursor..abs + 1]);
286                cursor = abs + 1;
287            }
288        }
289
290        replaced.push_str(&out[cursor..]);
291        out = replaced;
292    }
293
294    out
295}
296
297fn index_is_in_quote_or_comment(line: &str, index: usize) -> bool {
298    let bytes = line.as_bytes();
299    if index >= bytes.len() {
300        return false;
301    }
302
303    let mut in_single = false;
304    let mut in_double = false;
305    let mut escaped = false;
306
307    for (i, &byte) in bytes.iter().enumerate() {
308        if i == index {
309            return in_single || in_double;
310        }
311
312        let ch = byte as char;
313        if escaped {
314            escaped = false;
315            continue;
316        }
317
318        if in_single {
319            if ch == '\\' {
320                escaped = true;
321                continue;
322            }
323            if ch == '\'' {
324                in_single = false;
325            }
326            continue;
327        }
328
329        if in_double {
330            if ch == '\\' {
331                escaped = true;
332                continue;
333            }
334            if ch == '"' {
335                in_double = false;
336            }
337            continue;
338        }
339
340        if ch == '#' {
341            return i < index;
342        }
343
344        if ch == '\'' {
345            in_single = true;
346            continue;
347        }
348
349        if ch == '"' {
350            in_double = true;
351        }
352    }
353
354    false
355}
356
357/// Apply full-line `ModuleLineEdit` replacements to source text.
358#[must_use]
359pub fn apply_module_rename_edits(source: &str, edits: &[ModuleLineEdit]) -> String {
360    if edits.is_empty() {
361        return source.to_string();
362    }
363
364    let mut lines: Vec<String> = source.split('\n').map(ToString::to_string).collect();
365
366    let mut sorted = edits.to_vec();
367    sorted.sort_by_key(|edit| edit.line);
368
369    for edit in sorted {
370        if let Some(line) = lines.get_mut(edit.line) {
371            *line = edit.new_text;
372        }
373    }
374
375    lines.join("\n")
376}