Skip to main content

perl_module_rename/
lib.rs

1//! Deterministic module-import rename edit planning.
2//!
3//! This crate isolates the small but critical responsibility of computing line
4//! edits for Perl module file-rename workflows.
5
6#![deny(unsafe_code)]
7#![warn(rust_2018_idioms)]
8#![warn(missing_docs)]
9#![warn(clippy::all)]
10
11use perl_module_import_match::line_references_module_import;
12use perl_module_token::{module_variant_pairs, replace_module_token};
13
14/// A full-line replacement edit for a module rename.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ModuleLineEdit {
17    /// Zero-based source line index.
18    pub line: usize,
19    /// Start column (always `0` for full-line replacement).
20    pub start_character: usize,
21    /// End column of the original line in bytes.
22    pub end_character: usize,
23    /// Replacement text for the full line.
24    pub new_text: String,
25}
26
27/// Plan full-line edits needed to update module imports after file rename.
28///
29/// Supported import forms:
30/// - `use Module::Name;`
31/// - `require Module::Name;`
32/// - `use parent 'Module::Name';`
33/// - `use parent "Module::Name";`
34/// - `use parent qw(Module::Name Other);`
35/// - `use base 'Module::Name';`
36/// - `use base "Module::Name";`
37/// - `use base qw(Module::Name Other);`
38///
39/// Also rewrites:
40/// - Qualified function calls: `Module::Name::func()` → `NewName::func()`
41/// - Static method calls: `Module::Name->method()` → `NewName->method()`
42/// - `@ISA` array assignments: `@ISA = ('Module::Name')` → `@ISA = ('NewName')`
43/// - `our @ISA = qw(Module::Name)` → `our @ISA = qw(NewName)`
44///
45/// Legacy package separators (`Foo'Bar`) are also handled.
46#[must_use]
47pub fn plan_module_rename_edits(
48    source: &str,
49    old_module: &str,
50    new_module: &str,
51) -> Vec<ModuleLineEdit> {
52    if source.is_empty()
53        || old_module.is_empty()
54        || new_module.is_empty()
55        || old_module == new_module
56    {
57        return Vec::new();
58    }
59
60    let variants = module_variant_pairs(old_module, new_module);
61    let mut edits = Vec::new();
62
63    for (line_idx, line) in source.lines().enumerate() {
64        let mut rewritten: Option<String> = None;
65
66        for (old_variant, new_variant) in &variants {
67            // Re-read after each replacement so patterns compose correctly.
68            // A single source line may contain multiple pattern types
69            // (e.g. `@ISA = qw(Foo::Bar); Foo::Bar::init();`), and each
70            // replacement must see the latest working text.
71
72            // Check import forms (use/require/use parent/use base)
73            {
74                let current_line = rewritten.as_deref().unwrap_or(line);
75                if line_references_module_import(current_line, old_variant) {
76                    let (candidate, changed) =
77                        replace_module_token(current_line, old_variant, new_variant);
78                    if changed {
79                        rewritten = Some(candidate);
80                    }
81                }
82            }
83
84            // Check @ISA assignments: module name appears standalone in the line
85            // (e.g. `@ISA = ('Foo::Bar')`, `our @ISA = qw(Foo::Bar)`)
86            {
87                let current_line = rewritten.as_deref().unwrap_or(line);
88                if line_references_isa_assignment(current_line, old_variant) {
89                    let (candidate, changed) =
90                        replace_module_token(current_line, old_variant, new_variant);
91                    if changed {
92                        rewritten = Some(candidate);
93                    }
94                }
95            }
96
97            // Check qualified function calls: `Foo::Bar::func()` — the module
98            // name appears as a namespace prefix followed by `::`.
99            {
100                let current_line = rewritten.as_deref().unwrap_or(line);
101                if line_references_qualified_call(current_line, old_variant) {
102                    let candidate =
103                        replace_module_name_prefix(current_line, old_variant, new_variant);
104                    if candidate != current_line {
105                        rewritten = Some(candidate);
106                    }
107                }
108            }
109        }
110
111        if let Some(new_text) = rewritten {
112            edits.push(ModuleLineEdit {
113                line: line_idx,
114                start_character: 0,
115                end_character: line.len(),
116                new_text,
117            });
118        }
119    }
120
121    edits
122}
123
124/// Return `true` when `line` contains an `@ISA` assignment that references
125/// `module_name` as a standalone token.
126///
127/// Detects patterns such as:
128/// - `@ISA = ('Foo::Bar');`
129/// - `our @ISA = qw(Foo::Bar Other::Base);`
130/// - `push @ISA, 'Foo::Bar';`
131#[must_use]
132pub fn line_references_isa_assignment(line: &str, module_name: &str) -> bool {
133    if line.is_empty() || module_name.is_empty() {
134        return false;
135    }
136    // Must contain @ISA (case-sensitive — Perl's @ISA is always uppercase)
137    if !line.contains("@ISA") {
138        return false;
139    }
140    // The module name must appear as a standalone token on this line.
141    // We reuse the boundary-aware check from perl_module_token.
142    perl_module_token::contains_module_token(line, module_name)
143}
144
145/// Return `true` when `line` contains a qualified call that uses `module_name`
146/// as a namespace prefix.
147///
148/// Detects patterns such as:
149/// - `Foo::Bar::baz()` — direct qualified function call
150/// - `Foo::Bar->method()` is handled by `replace_module_token` directly
151///   (boundary check passes when `->` follows the module name)
152///
153/// A qualified call is identified when `module_name::` (with `::` suffix)
154/// appears in the line, preceded by a non-module-token character (or the
155/// start of the line), and followed by an identifier character.
156#[must_use]
157pub fn line_references_qualified_call(line: &str, module_name: &str) -> bool {
158    if line.is_empty() || module_name.is_empty() {
159        return false;
160    }
161    // Build the search needle: `module_name::`
162    let needle = format!("{}::", module_name);
163    let needle_bytes = needle.as_bytes();
164    let line_bytes = line.as_bytes();
165    let needle_len = needle_bytes.len();
166
167    if line_bytes.len() < needle_len {
168        return false;
169    }
170
171    let mut start = 0usize;
172    while start + needle_len <= line_bytes.len() {
173        let Some(rel) = line[start..].find(needle.as_str()) else {
174            break;
175        };
176        let abs = start + rel;
177        let after = abs + needle_len;
178
179        // Verify: the character before the match is not a module-token char
180        // (rejects matches embedded in a longer module path like `Extra::Foo::Bar::baz`)
181        let before_ok = abs == 0 || {
182            let ch = line_bytes[abs - 1] as char;
183            !ch.is_alphanumeric() && ch != '_' && ch != ':'
184        };
185
186        // Verify: the character after `module_name::` is an identifier start
187        // (rejects trailing `::` that is not followed by a function name)
188        let after_ok = after < line_bytes.len() && {
189            let ch = line_bytes[after] as char;
190            ch.is_alphabetic() || ch == '_'
191        };
192
193        if before_ok && after_ok {
194            return true;
195        }
196        start = abs + 1;
197    }
198
199    false
200}
201
202/// Replace `old_module::` namespace prefixes in `line` with `new_module::`.
203///
204/// This handles qualified function calls like `Foo::Bar::baz()` where
205/// `Foo::Bar` is used as a namespace prefix (i.e., followed by `::`).
206/// The replacement is boundary-aware: it only replaces occurrences where
207/// the match is not embedded inside a longer namespace path.
208#[must_use]
209pub fn replace_module_name_prefix(line: &str, old_module: &str, new_module: &str) -> String {
210    if old_module.is_empty() || new_module.is_empty() || line.is_empty() {
211        return line.to_string();
212    }
213
214    let needle = format!("{}::", old_module);
215    let replacement = format!("{}::", new_module);
216    let needle_bytes = needle.as_bytes();
217    let needle_len = needle_bytes.len();
218    let line_bytes = line.as_bytes();
219
220    if line_bytes.len() < needle_len {
221        return line.to_string();
222    }
223
224    let mut out = String::with_capacity(line.len());
225    let mut cursor = 0usize;
226
227    while cursor + needle_len <= line_bytes.len() {
228        let Some(rel) = line[cursor..].find(needle.as_str()) else {
229            break;
230        };
231        let abs = cursor + rel;
232        let after = abs + needle_len;
233
234        let before_ok = abs == 0 || {
235            let ch = line_bytes[abs - 1] as char;
236            !ch.is_alphanumeric() && ch != '_' && ch != ':'
237        };
238
239        let after_ok = after < line_bytes.len() && {
240            let ch = line_bytes[after] as char;
241            ch.is_alphabetic() || ch == '_'
242        };
243
244        if before_ok && after_ok {
245            out.push_str(&line[cursor..abs]);
246            out.push_str(&replacement);
247            cursor = after;
248        } else {
249            // Advance past this non-matching occurrence
250            out.push_str(&line[cursor..abs + 1]);
251            cursor = abs + 1;
252        }
253    }
254
255    out.push_str(&line[cursor..]);
256    out
257}
258
259/// Apply full-line `ModuleLineEdit` replacements to source text.
260#[must_use]
261pub fn apply_module_rename_edits(source: &str, edits: &[ModuleLineEdit]) -> String {
262    if edits.is_empty() {
263        return source.to_string();
264    }
265
266    let mut lines: Vec<String> = source.split('\n').map(ToString::to_string).collect();
267
268    let mut sorted = edits.to_vec();
269    sorted.sort_by_key(|edit| edit.line);
270
271    for edit in sorted {
272        if let Some(line) = lines.get_mut(edit.line) {
273            *line = edit.new_text;
274        }
275    }
276
277    lines.join("\n")
278}
279#[cfg(test)]
280mod tests {
281    use super::{
282        ModuleLineEdit, apply_module_rename_edits, line_references_isa_assignment,
283        line_references_qualified_call, plan_module_rename_edits, replace_module_name_prefix,
284    };
285    use perl_module_token::{module_variant_pairs, replace_module_token};
286
287    #[test]
288    fn plans_basic_use_and_require_edits() {
289        let source = "use Foo::Bar;\nrequire Foo::Bar;\n";
290        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Module");
291
292        assert_eq!(
293            edits,
294            vec![
295                ModuleLineEdit {
296                    line: 0,
297                    start_character: 0,
298                    end_character: "use Foo::Bar;".len(),
299                    new_text: "use New::Module;".to_string(),
300                },
301                ModuleLineEdit {
302                    line: 1,
303                    start_character: 0,
304                    end_character: "require Foo::Bar;".len(),
305                    new_text: "require New::Module;".to_string(),
306                },
307            ]
308        );
309    }
310
311    #[test]
312    fn plans_parent_and_base_edits() {
313        let source = "use parent 'Foo::Bar';\nuse base \"Foo::Bar\";\nuse parent qw(Foo::Bar Other::Base);\n";
314        let edits = plan_module_rename_edits(source, "Foo::Bar", "Renamed::Base");
315        let rewritten = apply_module_rename_edits(source, &edits);
316
317        let expected = "use parent 'Renamed::Base';\nuse base \"Renamed::Base\";\nuse parent qw(Renamed::Base Other::Base);\n";
318        assert_eq!(rewritten, expected);
319    }
320
321    #[test]
322    fn handles_legacy_separator_variants() {
323        let source = "use Foo'Bar;\nuse parent \"Foo'Bar\";\n";
324        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Path");
325        let rewritten = apply_module_rename_edits(source, &edits);
326
327        assert_eq!(rewritten, "use New'Path;\nuse parent \"New'Path\";\n");
328    }
329
330    #[test]
331    fn does_not_touch_partial_module_names() {
332        let source = "use Foo::Barista;\n";
333        let edits = plan_module_rename_edits(source, "Foo::Bar", "Renamed::Module");
334        assert!(edits.is_empty());
335    }
336
337    #[test]
338    fn apply_edits_replaces_target_lines_only() {
339        let source = "line1\nline2\nline3\n";
340        let edits = vec![ModuleLineEdit {
341            line: 1,
342            start_character: 0,
343            end_character: 5,
344            new_text: "updated".to_string(),
345        }];
346
347        let rewritten = apply_module_rename_edits(source, &edits);
348        assert_eq!(rewritten, "line1\nupdated\nline3\n");
349    }
350
351    #[test]
352    fn module_variant_generation_deduplicates_when_not_needed() {
353        let variants = module_variant_pairs("strict", "warnings");
354        assert_eq!(variants.len(), 1);
355    }
356
357    #[test]
358    fn token_replacement_requires_boundaries() {
359        let (rewritten, changed) = replace_module_token("use Foo::Barista;", "Foo::Bar", "X::Y");
360        assert_eq!(rewritten, "use Foo::Barista;");
361        assert!(!changed);
362
363        let (rewritten, changed) = replace_module_token("use Foo::Bar;", "Foo::Bar", "X::Y");
364        assert_eq!(rewritten, "use X::Y;");
365        assert!(changed);
366    }
367
368    #[test]
369    fn plans_use_parent_simple_name_no_colons() {
370        // Regression for #2747: use parent with simple name (no ::)
371        let source = "package Child;\nuse parent 'MyBase';\n1;\n";
372        let edits = plan_module_rename_edits(source, "MyBase", "RenamedBase");
373        let rewritten = apply_module_rename_edits(source, &edits);
374        assert!(
375            rewritten.contains("use parent 'RenamedBase'"),
376            "Expected rewrite of use parent 'MyBase' to 'RenamedBase', got: {:?}",
377            rewritten
378        );
379    }
380
381    // ── @ISA assignment detection ─────────────────────────────────────────────
382
383    #[test]
384    fn isa_assignment_detected_single_quoted() {
385        assert!(line_references_isa_assignment("@ISA = ('Foo::Bar');", "Foo::Bar"));
386    }
387
388    #[test]
389    fn isa_assignment_detected_double_quoted() {
390        assert!(line_references_isa_assignment("@ISA = (\"Foo::Bar\");", "Foo::Bar"));
391    }
392
393    #[test]
394    fn isa_assignment_detected_qw() {
395        assert!(line_references_isa_assignment("our @ISA = qw(Foo::Bar Other::Base);", "Foo::Bar"));
396    }
397
398    #[test]
399    fn isa_push_detected() {
400        assert!(line_references_isa_assignment("push @ISA, 'Foo::Bar';", "Foo::Bar"));
401    }
402
403    #[test]
404    fn isa_assignment_rejects_absent_module() {
405        assert!(!line_references_isa_assignment("@ISA = ('Other::Base');", "Foo::Bar"));
406    }
407
408    #[test]
409    fn isa_assignment_rejects_no_isa() {
410        assert!(!line_references_isa_assignment("use Foo::Bar;", "Foo::Bar"));
411    }
412
413    #[test]
414    fn isa_assignment_rejects_partial_module_name() {
415        // "Bar" must NOT match inside "Foo::Bar" when searching for "Bar"
416        // @ISA contains Foo::Bar, not Bar standalone
417        assert!(!line_references_isa_assignment("@ISA = ('Foo::Bar');", "Bar"));
418    }
419
420    // ── @ISA edit planning ────────────────────────────────────────────────────
421
422    #[test]
423    fn plans_isa_assignment_single_quoted() {
424        let source = "@ISA = ('Foo::Bar');\n";
425        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Module");
426        let rewritten = apply_module_rename_edits(source, &edits);
427        assert_eq!(rewritten, "@ISA = ('New::Module');\n");
428    }
429
430    #[test]
431    fn plans_isa_assignment_qw() {
432        let source = "our @ISA = qw(Foo::Bar Other::Base);\n";
433        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Module");
434        let rewritten = apply_module_rename_edits(source, &edits);
435        assert_eq!(rewritten, "our @ISA = qw(New::Module Other::Base);\n");
436    }
437
438    #[test]
439    fn plans_isa_push() {
440        let source = "push @ISA, 'Foo::Bar';\n";
441        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Module");
442        let rewritten = apply_module_rename_edits(source, &edits);
443        assert_eq!(rewritten, "push @ISA, 'New::Module';\n");
444    }
445
446    // ── Qualified call detection ──────────────────────────────────────────────
447
448    #[test]
449    fn qualified_call_detected_direct_function() {
450        assert!(line_references_qualified_call("Foo::Bar::baz();", "Foo::Bar"));
451    }
452
453    #[test]
454    fn qualified_call_detected_in_expression() {
455        assert!(line_references_qualified_call("my $x = Foo::Bar::create($arg);", "Foo::Bar"));
456    }
457
458    #[test]
459    fn qualified_call_rejects_standalone_module() {
460        // "use Foo::Bar;" — Foo::Bar is not a namespace prefix here
461        assert!(!line_references_qualified_call("use Foo::Bar;", "Foo::Bar"));
462    }
463
464    #[test]
465    fn qualified_call_rejects_deeper_prefix() {
466        // "Extra::Foo::Bar::baz()" — Foo::Bar is not at the boundary
467        assert!(!line_references_qualified_call("Extra::Foo::Bar::baz();", "Foo::Bar"));
468    }
469
470    #[test]
471    fn qualified_call_rejects_empty_inputs() {
472        assert!(!line_references_qualified_call("", "Foo::Bar"));
473        assert!(!line_references_qualified_call("Foo::Bar::baz();", ""));
474    }
475
476    // ── Qualified call edit planning ──────────────────────────────────────────
477
478    #[test]
479    fn plans_qualified_function_call() {
480        let source = "my $x = Foo::Bar::create($arg);\n";
481        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Module");
482        let rewritten = apply_module_rename_edits(source, &edits);
483        assert_eq!(rewritten, "my $x = New::Module::create($arg);\n");
484    }
485
486    #[test]
487    fn plans_qualified_call_preserves_function_name() {
488        let source = "Foo::Bar::baz();\n";
489        let edits = plan_module_rename_edits(source, "Foo::Bar", "Renamed::Pkg");
490        let rewritten = apply_module_rename_edits(source, &edits);
491        assert_eq!(rewritten, "Renamed::Pkg::baz();\n");
492    }
493
494    #[test]
495    fn plans_qualified_call_does_not_affect_deeper_prefix() {
496        // Extra::Foo::Bar::baz() — "Foo::Bar" is not a top-level namespace here
497        let source = "Extra::Foo::Bar::baz();\n";
498        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Module");
499        // Should produce no edits
500        assert!(edits.is_empty(), "Expected no edits, got: {:?}", edits);
501    }
502
503    #[test]
504    fn plans_multiple_qualified_calls_on_same_line() {
505        let source = "my $a = Foo::Bar::new(); my $b = Foo::Bar::clone();\n";
506        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Mod");
507        let rewritten = apply_module_rename_edits(source, &edits);
508        assert_eq!(rewritten, "my $a = New::Mod::new(); my $b = New::Mod::clone();\n");
509    }
510
511    // ── replace_module_name_prefix unit tests ─────────────────────────────────
512
513    #[test]
514    fn prefix_replace_basic() {
515        let result = replace_module_name_prefix("Foo::Bar::baz();", "Foo::Bar", "New::Mod");
516        assert_eq!(result, "New::Mod::baz();");
517    }
518
519    #[test]
520    fn prefix_replace_multiple_occurrences() {
521        let result =
522            replace_module_name_prefix("Foo::Bar::a() + Foo::Bar::b()", "Foo::Bar", "New::Mod");
523        assert_eq!(result, "New::Mod::a() + New::Mod::b()");
524    }
525
526    #[test]
527    fn prefix_replace_rejects_deeper_prefix() {
528        // Extra::Foo::Bar::baz() — Foo::Bar is not at a boundary
529        let result = replace_module_name_prefix("Extra::Foo::Bar::baz();", "Foo::Bar", "New::Mod");
530        assert_eq!(result, "Extra::Foo::Bar::baz();");
531    }
532
533    #[test]
534    fn prefix_replace_empty_inputs_are_noop() {
535        assert_eq!(replace_module_name_prefix("", "Foo::Bar", "New::Mod"), "");
536        assert_eq!(
537            replace_module_name_prefix("Foo::Bar::baz();", "", "New::Mod"),
538            "Foo::Bar::baz();"
539        );
540    }
541
542    // ── Combined: mixed import + @ISA + qualified call ────────────────────────
543
544    #[test]
545    fn plans_full_file_with_all_patterns() {
546        let source = "use Foo::Bar;\nour @ISA = qw(Foo::Bar);\nmy $x = Foo::Bar::create();\n";
547        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Module");
548        let rewritten = apply_module_rename_edits(source, &edits);
549        assert_eq!(
550            rewritten,
551            "use New::Module;\nour @ISA = qw(New::Module);\nmy $x = New::Module::create();\n"
552        );
553    }
554
555    #[test]
556    fn plans_isa_and_qualified_call_on_same_line() {
557        // When @ISA assignment and a qualified function call appear on the same
558        // source line, both must be rewritten. The ISA branch must not short-
559        // circuit via `continue` and skip the qualified-call branch.
560        let source = "@ISA = qw(Foo::Bar); Foo::Bar::init();\n";
561        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Mod");
562        let rewritten = apply_module_rename_edits(source, &edits);
563        assert_eq!(rewritten, "@ISA = qw(New::Mod); New::Mod::init();\n");
564    }
565
566    #[test]
567    fn plans_import_and_qualified_call_on_same_line() {
568        // Same principle: a `use` import and a qualified call on the same line.
569        // The import branch fires first via `continue`; the qualified call must
570        // also be rewritten in a subsequent pass.
571        let source = "use Foo::Bar; Foo::Bar::init();\n";
572        let edits = plan_module_rename_edits(source, "Foo::Bar", "New::Mod");
573        let rewritten = apply_module_rename_edits(source, &edits);
574        assert_eq!(rewritten, "use New::Mod; New::Mod::init();\n");
575    }
576}