Skip to main content

perl_module_token/
lib.rs

1//! Boundary-safe Perl module token replacement helpers.
2//!
3//! This crate provides a small, focused API used by module-rename workflows.
4//! It handles canonical (`Foo::Bar`) and legacy (`Foo'Bar`) separator variants
5//! and delegates standalone token scanning to `perl-module-boundary`.
6
7#![deny(unsafe_code)]
8#![warn(rust_2018_idioms)]
9#![warn(missing_docs)]
10#![warn(clippy::all)]
11
12use perl_module_boundary::{contains_standalone_module_token, find_standalone_module_token_ranges};
13
14/// Build canonical + legacy module rename pairs.
15///
16/// The returned vector always includes the canonical `::` pair. It also
17/// includes the legacy `'` pair when it differs.
18///
19/// # Examples
20///
21/// ```
22/// use perl_module_token::module_variant_pairs;
23///
24/// let variants = module_variant_pairs("Foo::Bar", "New::Path");
25/// assert_eq!(
26///     variants,
27///     vec![
28///         ("Foo::Bar".to_string(), "New::Path".to_string()),
29///         ("Foo'Bar".to_string(), "New'Path".to_string()),
30///     ]
31/// );
32/// ```
33pub use perl_module_name::module_variant_pairs;
34
35/// Returns `true` when `line` contains `module_name` as a standalone module
36/// token, respecting module boundaries.
37#[must_use]
38pub fn contains_module_token(line: &str, module_name: &str) -> bool {
39    contains_standalone_module_token(line, module_name)
40}
41
42/// Replace standalone `from` module token occurrences in `line` with `to`.
43///
44/// Returns `(rewritten_line, changed)`.
45#[must_use]
46pub fn replace_module_token(line: &str, from: &str, to: &str) -> (String, bool) {
47    if from.is_empty() || line.is_empty() {
48        return (line.to_string(), false);
49    }
50
51    let mut ranges = find_standalone_module_token_ranges(line, from).peekable();
52    if ranges.peek().is_none() {
53        return (line.to_string(), false);
54    }
55
56    let mut out = String::with_capacity(line.len());
57    let mut cursor = 0usize;
58
59    for range in ranges {
60        out.push_str(&line[cursor..range.start]);
61        out.push_str(to);
62        cursor = range.end;
63    }
64
65    out.push_str(&line[cursor..]);
66    (out, true)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::{contains_module_token, module_variant_pairs, replace_module_token};
72
73    #[test]
74    fn builds_canonical_and_legacy_variant_pairs() {
75        assert_eq!(
76            module_variant_pairs("Foo::Bar", "New::Path"),
77            vec![
78                ("Foo::Bar".to_string(), "New::Path".to_string()),
79                ("Foo'Bar".to_string(), "New'Path".to_string()),
80            ]
81        );
82    }
83
84    #[test]
85    fn canonicalizes_legacy_inputs_for_variant_pairs() {
86        assert_eq!(
87            module_variant_pairs("Foo'Bar", "New'Path"),
88            vec![
89                ("Foo::Bar".to_string(), "New::Path".to_string()),
90                ("Foo'Bar".to_string(), "New'Path".to_string()),
91            ]
92        );
93    }
94
95    #[test]
96    fn deduplicates_pair_when_no_separator_variants_exist() {
97        assert_eq!(module_variant_pairs("strict", "warnings").len(), 1);
98    }
99
100    #[test]
101    fn replaces_only_standalone_module_tokens() {
102        let (rewritten, changed) = replace_module_token("use Foo::Bar;", "Foo::Bar", "X::Y");
103        assert_eq!(rewritten, "use X::Y;");
104        assert!(changed);
105
106        let (rewritten, changed) = replace_module_token("use Foo::Barista;", "Foo::Bar", "X::Y");
107        assert_eq!(rewritten, "use Foo::Barista;");
108        assert!(!changed);
109    }
110
111    #[test]
112    fn treats_legacy_separator_as_module_character_boundary() {
113        let (rewritten, changed) = replace_module_token("use Foo'Bar'Baz;", "Foo'Bar", "X'Y");
114        assert_eq!(rewritten, "use Foo'Bar'Baz;");
115        assert!(!changed);
116    }
117
118    #[test]
119    fn contains_matches_boundary_aware_token_presence() {
120        assert!(contains_module_token("use parent 'Foo::Bar';", "Foo::Bar"));
121        assert!(!contains_module_token("use Foo::Barista;", "Foo::Bar"));
122    }
123
124    // ── Simple module names ──────────────────────────────────────
125
126    #[test]
127    fn contains_simple_bare_name_foo() {
128        assert!(contains_module_token("use Foo;", "Foo"));
129    }
130
131    #[test]
132    fn contains_simple_two_segment_foo_bar() {
133        assert!(contains_module_token("use Foo::Bar;", "Foo::Bar"));
134    }
135
136    #[test]
137    fn replace_simple_bare_name_foo() {
138        let (out, changed) = replace_module_token("use Foo;", "Foo", "Bar");
139        assert!(changed);
140        assert_eq!(out, "use Bar;");
141    }
142
143    #[test]
144    fn replace_simple_two_segment_foo_bar() {
145        let (out, changed) = replace_module_token("use Foo::Bar;", "Foo::Bar", "Baz::Qux");
146        assert!(changed);
147        assert_eq!(out, "use Baz::Qux;");
148    }
149
150    #[test]
151    fn contains_rejects_simple_name_as_substring() {
152        assert!(!contains_module_token("use Foobar;", "Foo"));
153    }
154
155    // ── Deeply nested: Foo::Bar::Baz::Qux ───────────────────────
156
157    #[test]
158    fn contains_four_segment_deeply_nested() {
159        assert!(contains_module_token("use Foo::Bar::Baz::Qux;", "Foo::Bar::Baz::Qux"));
160    }
161
162    #[test]
163    fn replace_four_segment_deeply_nested() {
164        let (out, changed) = replace_module_token(
165            "use Foo::Bar::Baz::Qux;",
166            "Foo::Bar::Baz::Qux",
167            "One::Two::Three::Four",
168        );
169        assert!(changed);
170        assert_eq!(out, "use One::Two::Three::Four;");
171    }
172
173    #[test]
174    fn contains_rejects_prefix_of_deeply_nested() {
175        // "Foo::Bar" is a prefix of "Foo::Bar::Baz::Qux", not standalone
176        assert!(!contains_module_token("use Foo::Bar::Baz::Qux;", "Foo::Bar"));
177    }
178
179    #[test]
180    fn contains_rejects_suffix_of_deeply_nested() {
181        // "Baz::Qux" is embedded in the larger token
182        assert!(!contains_module_token("use Foo::Bar::Baz::Qux;", "Baz::Qux"));
183    }
184
185    #[test]
186    fn replace_does_not_modify_prefix_of_deeply_nested() {
187        let (out, changed) = replace_module_token("use Foo::Bar::Baz::Qux;", "Foo::Bar", "X::Y");
188        assert!(!changed);
189        assert_eq!(out, "use Foo::Bar::Baz::Qux;");
190    }
191
192    #[test]
193    fn contains_deeply_nested_in_method_call() {
194        assert!(contains_module_token("Foo::Bar::Baz::Qux->new()", "Foo::Bar::Baz::Qux"));
195    }
196
197    #[test]
198    fn replace_deeply_nested_in_method_call() {
199        let (out, changed) =
200            replace_module_token("Foo::Bar::Baz::Qux->new()", "Foo::Bar::Baz::Qux", "A::B::C::D");
201        assert!(changed);
202        assert_eq!(out, "A::B::C::D->new()");
203    }
204
205    #[test]
206    fn variant_pairs_deeply_nested_four_segments() {
207        let pairs = module_variant_pairs("Foo::Bar::Baz::Qux", "One::Two::Three::Four");
208        assert_eq!(pairs.len(), 2);
209        assert_eq!(
210            pairs[0],
211            ("Foo::Bar::Baz::Qux".to_string(), "One::Two::Three::Four".to_string())
212        );
213        assert_eq!(pairs[1], ("Foo'Bar'Baz'Qux".to_string(), "One'Two'Three'Four".to_string()));
214    }
215
216    // ── Module name validation via boundary checks ───────────────
217
218    #[test]
219    fn contains_rejects_empty_module_name() {
220        assert!(!contains_module_token("use Foo::Bar;", ""));
221    }
222
223    #[test]
224    fn contains_rejects_empty_line() {
225        assert!(!contains_module_token("", "Foo::Bar"));
226    }
227
228    #[test]
229    fn replace_empty_from_is_noop() {
230        let (out, changed) = replace_module_token("use Foo::Bar;", "", "X::Y");
231        assert!(!changed);
232        assert_eq!(out, "use Foo::Bar;");
233    }
234
235    #[test]
236    fn replace_empty_line_is_noop() {
237        let (out, changed) = replace_module_token("", "Foo::Bar", "X::Y");
238        assert!(!changed);
239        assert_eq!(out, "");
240    }
241
242    #[test]
243    fn contains_validates_boundary_before_token() {
244        // Digit before module name means it's part of a larger identifier
245        assert!(!contains_module_token("use 1Foo::Bar;", "Foo::Bar"));
246    }
247
248    #[test]
249    fn contains_validates_boundary_after_token() {
250        // Letter after module name means it's part of a larger identifier
251        assert!(!contains_module_token("use Foo::BarX;", "Foo::Bar"));
252    }
253
254    // ── Module name components via rename workflow ────────────────
255
256    #[test]
257    fn rename_workflow_four_segment_canonical() {
258        let pairs = module_variant_pairs("Foo::Bar::Baz::Qux", "A::B::C::D");
259        let line = "use Foo::Bar::Baz::Qux;";
260
261        let mut result = line.to_string();
262        for (old, new) in &pairs {
263            let (candidate, changed) = replace_module_token(&result, old, new);
264            if changed {
265                result = candidate;
266            }
267        }
268        assert_eq!(result, "use A::B::C::D;");
269    }
270
271    #[test]
272    fn rename_workflow_four_segment_legacy() {
273        let pairs = module_variant_pairs("Foo::Bar::Baz::Qux", "A::B::C::D");
274        let line = "use Foo'Bar'Baz'Qux;";
275
276        let mut result = line.to_string();
277        for (old, new) in &pairs {
278            let (candidate, changed) = replace_module_token(&result, old, new);
279            if changed {
280                result = candidate;
281            }
282        }
283        assert_eq!(result, "use A'B'C'D;");
284    }
285
286    // ── Edge cases: single word module ───────────────────────────
287
288    #[test]
289    fn contains_single_word_module() {
290        assert!(contains_module_token("use strict;", "strict"));
291        assert!(contains_module_token("use warnings;", "warnings"));
292        assert!(contains_module_token("use DBI;", "DBI"));
293    }
294
295    #[test]
296    fn replace_single_word_module() {
297        let (out, changed) = replace_module_token("use strict;", "strict", "warnings");
298        assert!(changed);
299        assert_eq!(out, "use warnings;");
300    }
301
302    #[test]
303    fn single_word_not_matched_as_substring() {
304        assert!(!contains_module_token("use strictures;", "strict"));
305        assert!(!contains_module_token("use astrict;", "strict"));
306    }
307
308    #[test]
309    fn variant_pairs_single_word_produces_one_pair() {
310        let pairs = module_variant_pairs("DBI", "DBIx");
311        assert_eq!(pairs.len(), 1);
312        assert_eq!(pairs[0], ("DBI".to_string(), "DBIx".to_string()));
313    }
314
315    // ── Edge cases: trailing :: in search context ────────────────
316
317    #[test]
318    fn contains_does_not_match_when_followed_by_colon_colon() {
319        // Foo::Bar is followed by :: making it part of Foo::Bar::Baz
320        assert!(!contains_module_token("use Foo::Bar::Baz;", "Foo::Bar"));
321    }
322
323    #[test]
324    fn replace_does_not_modify_when_followed_by_colon_colon() {
325        let (out, changed) = replace_module_token("use Foo::Bar::Baz;", "Foo::Bar", "X::Y");
326        assert!(!changed);
327        assert_eq!(out, "use Foo::Bar::Baz;");
328    }
329
330    // ── CPAN real-world deeply nested modules ────────────────────
331
332    #[test]
333    fn contains_cpan_catalyst_plugin() {
334        assert!(contains_module_token(
335            "use Catalyst::Plugin::Authentication;",
336            "Catalyst::Plugin::Authentication"
337        ));
338    }
339
340    #[test]
341    fn replace_cpan_catalyst_plugin() {
342        let (out, changed) = replace_module_token(
343            "use Catalyst::Plugin::Authentication;",
344            "Catalyst::Plugin::Authentication",
345            "Catalyst::Plugin::AuthN",
346        );
347        assert!(changed);
348        assert_eq!(out, "use Catalyst::Plugin::AuthN;");
349    }
350
351    #[test]
352    fn replace_cpan_app_prove_five_segments() {
353        let (out, changed) = replace_module_token(
354            "use App::Prove::State::Result::Test;",
355            "App::Prove::State::Result::Test",
356            "TAP::Result::Test",
357        );
358        assert!(changed);
359        assert_eq!(out, "use TAP::Result::Test;");
360    }
361
362    #[test]
363    fn contains_and_replace_agree_on_deeply_nested() {
364        let line = "my $obj = Foo::Bar::Baz::Qux->new;";
365        let module = "Foo::Bar::Baz::Qux";
366        let present = contains_module_token(line, module);
367        let (_, changed) = replace_module_token(line, module, "X");
368        assert_eq!(present, changed);
369    }
370
371    #[test]
372    fn contains_and_replace_agree_on_prefix_of_deeply_nested() {
373        let line = "my $obj = Foo::Bar::Baz::Qux->new;";
374        let module = "Foo::Bar";
375        let present = contains_module_token(line, module);
376        let (_, changed) = replace_module_token(line, module, "X");
377        assert_eq!(present, changed);
378    }
379
380    // ── Underscore-only and special identifier modules ───────────
381
382    #[test]
383    fn contains_underscore_prefixed_module() {
384        assert!(contains_module_token("use _Private::Module;", "_Private::Module"));
385    }
386
387    #[test]
388    fn replace_underscore_prefixed_module() {
389        let (out, changed) =
390            replace_module_token("use _Private::Module;", "_Private::Module", "Public::Module");
391        assert!(changed);
392        assert_eq!(out, "use Public::Module;");
393    }
394
395    #[test]
396    fn contains_all_underscore_segments() {
397        assert!(contains_module_token("use _::_::_;", "_::_::_"));
398    }
399
400    // ── Multiple occurrences with deeply nested ──────────────────
401
402    #[test]
403    fn replace_multiple_occurrences_deeply_nested() {
404        let line = "use Foo::Bar::Baz; my $x = Foo::Bar::Baz->new;";
405        let (out, changed) = replace_module_token(line, "Foo::Bar::Baz", "A::B::C");
406        assert!(changed);
407        assert_eq!(out, "use A::B::C; my $x = A::B::C->new;");
408    }
409}