1use crate::diff::{Change, ChangeContext, FileChanges, OpResult};
2use crate::error::RipsedError;
3use crate::matcher::Matcher;
4use crate::operation::{LineRange, Op, TransformMode};
5use crate::undo::UndoEntry;
6
7#[derive(Debug)]
9pub struct EngineOutput {
10 pub text: Option<String>,
12 pub changes: Vec<Change>,
14 pub undo: Option<UndoEntry>,
16}
17
18enum LineAction {
23 Unchanged,
25 Replaced { new_line: String, change: Change },
27 Deleted { change: Change },
29 InsertedAfter { content: String, change: Change },
31 InsertedBefore { content: String, change: Change },
33}
34
35fn uses_crlf(text: &str) -> bool {
43 let crlf_count = text.matches("\r\n").count();
44 let lf_count = text.matches('\n').count() - crlf_count;
45 crlf_count > lf_count
46}
47
48struct LineCtx<'a> {
58 line: &'a str,
59 line_num: usize,
60 matcher: &'a Matcher,
61 lines: &'a [&'a str],
62 idx: usize,
63 context_lines: usize,
64}
65
66impl LineCtx<'_> {
67 fn build_context(&self) -> ChangeContext {
68 build_context(self.lines, self.idx, self.context_lines)
69 }
70}
71
72fn apply_replace(cx: &LineCtx, replace: &str) -> LineAction {
74 if let Some(replaced) = cx.matcher.replace(cx.line, replace) {
75 LineAction::Replaced {
76 new_line: replaced.clone(),
77 change: Change {
78 line: cx.line_num,
79 before: cx.line.to_string(),
80 after: Some(replaced),
81 context: Some(cx.build_context()),
82 },
83 }
84 } else {
85 LineAction::Unchanged
86 }
87}
88
89fn apply_delete(cx: &LineCtx) -> LineAction {
91 if cx.matcher.is_match(cx.line) {
92 LineAction::Deleted {
93 change: Change {
94 line: cx.line_num,
95 before: cx.line.to_string(),
96 after: None,
97 context: Some(cx.build_context()),
98 },
99 }
100 } else {
101 LineAction::Unchanged
102 }
103}
104
105fn apply_insert_after(cx: &LineCtx, content: &str) -> LineAction {
107 if cx.matcher.is_match(cx.line) {
108 LineAction::InsertedAfter {
109 content: content.to_string(),
110 change: Change {
111 line: cx.line_num,
112 before: cx.line.to_string(),
113 after: Some(format!("{}\n{content}", cx.line)),
114 context: Some(cx.build_context()),
115 },
116 }
117 } else {
118 LineAction::Unchanged
119 }
120}
121
122fn apply_insert_before(cx: &LineCtx, content: &str) -> LineAction {
124 if cx.matcher.is_match(cx.line) {
125 LineAction::InsertedBefore {
126 content: content.to_string(),
127 change: Change {
128 line: cx.line_num,
129 before: cx.line.to_string(),
130 after: Some(format!("{content}\n{}", cx.line)),
131 context: Some(cx.build_context()),
132 },
133 }
134 } else {
135 LineAction::Unchanged
136 }
137}
138
139fn apply_replace_line(cx: &LineCtx, content: &str) -> LineAction {
141 if cx.matcher.is_match(cx.line) {
142 LineAction::Replaced {
143 new_line: content.to_string(),
144 change: Change {
145 line: cx.line_num,
146 before: cx.line.to_string(),
147 after: Some(content.to_string()),
148 context: Some(cx.build_context()),
149 },
150 }
151 } else {
152 LineAction::Unchanged
153 }
154}
155
156fn apply_transform_op(cx: &LineCtx, mode: TransformMode) -> LineAction {
158 if cx.matcher.is_match(cx.line) {
159 let new_line = apply_transform(cx.line, cx.matcher, mode);
160 if new_line != cx.line {
161 LineAction::Replaced {
162 new_line: new_line.clone(),
163 change: Change {
164 line: cx.line_num,
165 before: cx.line.to_string(),
166 after: Some(new_line),
167 context: Some(cx.build_context()),
168 },
169 }
170 } else {
171 LineAction::Unchanged
172 }
173 } else {
174 LineAction::Unchanged
175 }
176}
177
178fn apply_surround(cx: &LineCtx, prefix: &str, suffix: &str) -> LineAction {
180 if cx.matcher.is_match(cx.line) {
181 let new_line = format!("{prefix}{}{suffix}", cx.line);
182 if new_line != cx.line {
183 LineAction::Replaced {
184 new_line: new_line.clone(),
185 change: Change {
186 line: cx.line_num,
187 before: cx.line.to_string(),
188 after: Some(new_line),
189 context: Some(cx.build_context()),
190 },
191 }
192 } else {
193 LineAction::Unchanged
194 }
195 } else {
196 LineAction::Unchanged
197 }
198}
199
200fn apply_indent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
202 if cx.matcher.is_match(cx.line) {
203 let indent = if use_tabs {
204 "\t".repeat(amount)
205 } else {
206 " ".repeat(amount)
207 };
208 let new_line = format!("{indent}{}", cx.line);
209 if new_line != cx.line {
210 LineAction::Replaced {
211 new_line: new_line.clone(),
212 change: Change {
213 line: cx.line_num,
214 before: cx.line.to_string(),
215 after: Some(new_line),
216 context: Some(cx.build_context()),
217 },
218 }
219 } else {
220 LineAction::Unchanged
221 }
222 } else {
223 LineAction::Unchanged
224 }
225}
226
227fn apply_dedent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
229 if cx.matcher.is_match(cx.line) {
230 let new_line = dedent_line(cx.line, amount, use_tabs);
231 if new_line != cx.line {
232 LineAction::Replaced {
233 new_line: new_line.clone(),
234 change: Change {
235 line: cx.line_num,
236 before: cx.line.to_string(),
237 after: Some(new_line),
238 context: Some(cx.build_context()),
239 },
240 }
241 } else {
242 LineAction::Unchanged
243 }
244 } else {
245 LineAction::Unchanged
246 }
247}
248
249pub fn apply(
254 text: &str,
255 op: &Op,
256 matcher: &Matcher,
257 line_range: Option<LineRange>,
258 context_lines: usize,
259) -> Result<EngineOutput, RipsedError> {
260 let crlf = uses_crlf(text);
261 let lines: Vec<&str> = text.lines().collect();
262 let mut result_lines: Vec<String> = Vec::with_capacity(lines.len());
263 let mut changes: Vec<Change> = Vec::new();
264
265 for (idx, &line) in lines.iter().enumerate() {
266 let line_num = idx + 1; if let Some(range) = line_range {
270 if !range.contains(line_num) {
271 result_lines.push(line.to_string());
272 continue;
273 }
274 }
275
276 let cx = LineCtx {
277 line,
278 line_num,
279 matcher,
280 lines: &lines,
281 idx,
282 context_lines,
283 };
284
285 let action = match op {
286 Op::Replace { replace, .. } => apply_replace(&cx, replace),
287 Op::Delete { .. } => apply_delete(&cx),
288 Op::InsertAfter { content, .. } => apply_insert_after(&cx, content),
289 Op::InsertBefore { content, .. } => apply_insert_before(&cx, content),
290 Op::ReplaceLine { content, .. } => apply_replace_line(&cx, content),
291 Op::Transform { mode, .. } => apply_transform_op(&cx, *mode),
292 Op::Surround { prefix, suffix, .. } => apply_surround(&cx, prefix, suffix),
293 Op::Indent {
294 amount, use_tabs, ..
295 } => apply_indent(&cx, *amount, *use_tabs),
296 Op::Dedent {
297 amount, use_tabs, ..
298 } => apply_dedent(&cx, *amount, *use_tabs),
299 };
300
301 match action {
302 LineAction::Unchanged => {
303 result_lines.push(line.to_string());
304 }
305 LineAction::Replaced { new_line, change } => {
306 changes.push(change);
307 result_lines.push(new_line);
308 }
309 LineAction::Deleted { change } => {
310 changes.push(change);
311 }
313 LineAction::InsertedAfter { content, change } => {
314 result_lines.push(line.to_string());
315 changes.push(change);
316 result_lines.push(content);
317 }
318 LineAction::InsertedBefore { content, change } => {
319 changes.push(change);
320 result_lines.push(content);
321 result_lines.push(line.to_string());
322 }
323 }
324 }
325
326 let line_sep = if crlf { "\r\n" } else { "\n" };
327 let modified_text = if changes.is_empty() {
328 None
329 } else {
330 let mut joined = result_lines.join(line_sep);
332 if text.ends_with('\n') || text.ends_with("\r\n") {
333 joined.push_str(line_sep);
334 }
335 Some(joined)
336 };
337
338 let undo = if !changes.is_empty() {
339 Some(UndoEntry {
340 original_text: text.to_string(),
341 })
342 } else {
343 None
344 };
345
346 Ok(EngineOutput {
347 text: modified_text,
348 changes,
349 undo,
350 })
351}
352
353fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
355 match matcher {
356 Matcher::Literal { pattern, .. } => {
357 line.replace(pattern.as_str(), &transform_text(pattern, mode))
358 }
359 Matcher::Regex(re) => {
360 let result = re.replace_all(line, |caps: ®ex::Captures| {
361 transform_text(&caps[0], mode)
362 });
363 result.into_owned()
364 }
365 }
366}
367
368fn transform_text(text: &str, mode: TransformMode) -> String {
370 match mode {
371 TransformMode::Upper => text.to_uppercase(),
372 TransformMode::Lower => text.to_lowercase(),
373 TransformMode::Title => {
374 let mut result = String::with_capacity(text.len());
375 let mut capitalize_next = true;
376 for ch in text.chars() {
377 if ch.is_whitespace() || ch == '_' || ch == '-' {
378 result.push(ch);
379 capitalize_next = true;
380 } else if capitalize_next {
381 for upper in ch.to_uppercase() {
382 result.push(upper);
383 }
384 capitalize_next = false;
385 } else {
386 result.push(ch);
387 }
388 }
389 result
390 }
391 TransformMode::SnakeCase => {
392 let mut result = String::with_capacity(text.len() + 4);
393 let mut prev_was_lower = false;
394 for ch in text.chars() {
395 if ch.is_uppercase() {
396 if prev_was_lower {
397 result.push('_');
398 }
399 for lower in ch.to_lowercase() {
400 result.push(lower);
401 }
402 prev_was_lower = false;
403 } else if ch == '-' || ch == ' ' {
404 result.push('_');
405 prev_was_lower = false;
406 } else {
407 result.push(ch);
408 prev_was_lower = ch.is_lowercase();
409 }
410 }
411 result
412 }
413 TransformMode::CamelCase => {
414 let mut result = String::with_capacity(text.len());
415 let mut capitalize_next = false;
416 let mut first = true;
417 for ch in text.chars() {
418 if ch == '_' || ch == '-' || ch == ' ' {
419 capitalize_next = true;
420 } else if capitalize_next {
421 for upper in ch.to_uppercase() {
422 result.push(upper);
423 }
424 capitalize_next = false;
425 } else if first {
426 for lower in ch.to_lowercase() {
427 result.push(lower);
428 }
429 first = false;
430 } else {
431 result.push(ch);
432 first = false;
433 }
434 }
435 result
436 }
437 }
438}
439
440fn dedent_line(line: &str, amount: usize, use_tabs: bool) -> String {
443 let ch = if use_tabs { '\t' } else { ' ' };
444 let leading = line.len() - line.trim_start_matches(ch).len();
445 let remove = leading.min(amount);
446 line[remove..].to_string()
447}
448
449fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
450 let start = idx.saturating_sub(context_lines);
451 let end = (idx + context_lines + 1).min(lines.len());
452
453 let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
454 let after = if idx + 1 < end {
455 lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
456 } else {
457 vec![]
458 };
459
460 ChangeContext { before, after }
461}
462
463pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
465 OpResult {
466 operation_index,
467 files: if changes.is_empty() {
468 vec![]
469 } else {
470 vec![FileChanges {
471 path: path.to_string(),
472 changes,
473 }]
474 },
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::matcher::Matcher;
482
483 #[test]
484 fn test_simple_replace() {
485 let text = "hello world\nfoo bar\nhello again\n";
486 let op = Op::Replace {
487 find: "hello".to_string(),
488 replace: "hi".to_string(),
489 regex: false,
490 case_insensitive: false,
491 };
492 let matcher = Matcher::new(&op).unwrap();
493 let result = apply(text, &op, &matcher, None, 2).unwrap();
494 assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
495 assert_eq!(result.changes.len(), 2);
496 }
497
498 #[test]
499 fn test_delete_lines() {
500 let text = "keep\ndelete me\nkeep too\n";
501 let op = Op::Delete {
502 find: "delete".to_string(),
503 regex: false,
504 case_insensitive: false,
505 };
506 let matcher = Matcher::new(&op).unwrap();
507 let result = apply(text, &op, &matcher, None, 0).unwrap();
508 assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
509 }
510
511 #[test]
512 fn test_no_changes() {
513 let text = "nothing matches here\n";
514 let op = Op::Replace {
515 find: "zzz".to_string(),
516 replace: "aaa".to_string(),
517 regex: false,
518 case_insensitive: false,
519 };
520 let matcher = Matcher::new(&op).unwrap();
521 let result = apply(text, &op, &matcher, None, 0).unwrap();
522 assert!(result.text.is_none());
523 assert!(result.changes.is_empty());
524 }
525
526 #[test]
527 fn test_line_range() {
528 let text = "line1\nline2\nline3\nline4\n";
529 let op = Op::Replace {
530 find: "line".to_string(),
531 replace: "row".to_string(),
532 regex: false,
533 case_insensitive: false,
534 };
535 let range = Some(LineRange {
536 start: 2,
537 end: Some(3),
538 });
539 let matcher = Matcher::new(&op).unwrap();
540 let result = apply(text, &op, &matcher, range, 0).unwrap();
541 assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
542 }
543
544 #[test]
549 fn test_crlf_replace_preserves_crlf() {
550 let text = "hello world\r\nfoo bar\r\nhello again\r\n";
551 let op = Op::Replace {
552 find: "hello".to_string(),
553 replace: "hi".to_string(),
554 regex: false,
555 case_insensitive: false,
556 };
557 let matcher = Matcher::new(&op).unwrap();
558 let result = apply(text, &op, &matcher, None, 0).unwrap();
559 assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
560 }
561
562 #[test]
563 fn test_crlf_delete_preserves_crlf() {
564 let text = "keep\r\ndelete me\r\nkeep too\r\n";
565 let op = Op::Delete {
566 find: "delete".to_string(),
567 regex: false,
568 case_insensitive: false,
569 };
570 let matcher = Matcher::new(&op).unwrap();
571 let result = apply(text, &op, &matcher, None, 0).unwrap();
572 assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
573 }
574
575 #[test]
576 fn test_crlf_no_trailing_newline() {
577 let text = "hello world\r\nfoo bar";
578 let op = Op::Replace {
579 find: "hello".to_string(),
580 replace: "hi".to_string(),
581 regex: false,
582 case_insensitive: false,
583 };
584 let matcher = Matcher::new(&op).unwrap();
585 let result = apply(text, &op, &matcher, None, 0).unwrap();
586 let output = result.text.unwrap();
587 assert_eq!(output, "hi world\r\nfoo bar");
588 assert!(!output.ends_with("\r\n"));
590 }
591
592 #[test]
593 fn test_uses_crlf_detection() {
594 assert!(uses_crlf("a\r\nb\r\n"));
595 assert!(uses_crlf("a\r\n"));
596 assert!(!uses_crlf("a\nb\n"));
597 assert!(!uses_crlf("no newline at all"));
598 assert!(!uses_crlf(""));
599 }
600
601 #[test]
606 fn test_empty_input_text() {
607 let text = "";
608 let op = Op::Replace {
609 find: "anything".to_string(),
610 replace: "something".to_string(),
611 regex: false,
612 case_insensitive: false,
613 };
614 let matcher = Matcher::new(&op).unwrap();
615 let result = apply(text, &op, &matcher, None, 0).unwrap();
616 assert!(result.text.is_none());
617 assert!(result.changes.is_empty());
618 }
619
620 #[test]
621 fn test_single_line_no_trailing_newline() {
622 let text = "hello world";
623 let op = Op::Replace {
624 find: "hello".to_string(),
625 replace: "hi".to_string(),
626 regex: false,
627 case_insensitive: false,
628 };
629 let matcher = Matcher::new(&op).unwrap();
630 let result = apply(text, &op, &matcher, None, 0).unwrap();
631 let output = result.text.unwrap();
632 assert_eq!(output, "hi world");
633 assert!(!output.ends_with('\n'));
635 }
636
637 #[test]
638 fn test_whitespace_only_lines() {
639 let text = " \n\t\n \t \n";
640 let op = Op::Replace {
641 find: "\t".to_string(),
642 replace: "TAB".to_string(),
643 regex: false,
644 case_insensitive: false,
645 };
646 let matcher = Matcher::new(&op).unwrap();
647 let result = apply(text, &op, &matcher, None, 0).unwrap();
648 let output = result.text.unwrap();
649 assert!(output.contains("TAB"));
650 assert_eq!(result.changes.len(), 2); }
652
653 #[test]
654 fn test_very_long_line() {
655 let long_word = "x".repeat(100_000);
656 let text = format!("before\n{long_word}\nafter\n");
657 let op = Op::Replace {
658 find: "x".to_string(),
659 replace: "y".to_string(),
660 regex: false,
661 case_insensitive: false,
662 };
663 let matcher = Matcher::new(&op).unwrap();
664 let result = apply(&text, &op, &matcher, None, 0).unwrap();
665 let output = result.text.unwrap();
666 let expected_long = "y".repeat(100_000);
667 assert!(output.contains(&expected_long));
668 }
669
670 #[test]
671 fn test_unicode_emoji() {
672 let text = "hello world\n";
673 let op = Op::Replace {
674 find: "world".to_string(),
675 replace: "\u{1F30D}".to_string(), regex: false,
677 case_insensitive: false,
678 };
679 let matcher = Matcher::new(&op).unwrap();
680 let result = apply(text, &op, &matcher, None, 0).unwrap();
681 assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
682 }
683
684 #[test]
685 fn test_unicode_cjk() {
686 let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; let op = Op::Replace {
688 find: "\u{4E16}\u{754C}".to_string(), replace: "\u{5730}\u{7403}".to_string(), regex: false,
691 case_insensitive: false,
692 };
693 let matcher = Matcher::new(&op).unwrap();
694 let result = apply(text, &op, &matcher, None, 0).unwrap();
695 assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
696 }
697
698 #[test]
699 fn test_unicode_combining_characters() {
700 let text = "caf\u{0065}\u{0301}\n";
702 let op = Op::Replace {
703 find: "caf\u{0065}\u{0301}".to_string(),
704 replace: "coffee".to_string(),
705 regex: false,
706 case_insensitive: false,
707 };
708 let matcher = Matcher::new(&op).unwrap();
709 let result = apply(text, &op, &matcher, None, 0).unwrap();
710 assert_eq!(result.text.unwrap(), "coffee\n");
711 }
712
713 #[test]
714 fn test_regex_special_chars_in_literal_mode() {
715 let text = "price is $10.00 (USD)\n";
717 let op = Op::Replace {
718 find: "$10.00".to_string(),
719 replace: "$20.00".to_string(),
720 regex: false,
721 case_insensitive: false,
722 };
723 let matcher = Matcher::new(&op).unwrap();
724 let result = apply(text, &op, &matcher, None, 0).unwrap();
725 assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
726 }
727
728 #[test]
729 fn test_overlapping_matches_in_single_line() {
730 let text = "aaa\n";
732 let op = Op::Replace {
733 find: "aa".to_string(),
734 replace: "b".to_string(),
735 regex: false,
736 case_insensitive: false,
737 };
738 let matcher = Matcher::new(&op).unwrap();
739 let result = apply(text, &op, &matcher, None, 0).unwrap();
740 assert_eq!(result.text.unwrap(), "ba\n");
742 }
743
744 #[test]
745 fn test_replace_line_count_preserved() {
746 let text = "line1\nline2\nline3\nline4\nline5\n";
747 let input_line_count = text.lines().count();
748 let op = Op::Replace {
749 find: "line".to_string(),
750 replace: "row".to_string(),
751 regex: false,
752 case_insensitive: false,
753 };
754 let matcher = Matcher::new(&op).unwrap();
755 let result = apply(text, &op, &matcher, None, 0).unwrap();
756 let output = result.text.unwrap();
757 let output_line_count = output.lines().count();
758 assert_eq!(input_line_count, output_line_count);
759 }
760
761 #[test]
762 fn test_replace_preserves_empty_result_on_non_match() {
763 let text = "alpha\nbeta\ngamma\n";
765 let op = Op::Replace {
766 find: "zzzzzz".to_string(),
767 replace: "y".to_string(),
768 regex: false,
769 case_insensitive: false,
770 };
771 let matcher = Matcher::new(&op).unwrap();
772 let result = apply(text, &op, &matcher, None, 0).unwrap();
773 assert!(result.text.is_none());
774 assert!(result.undo.is_none());
775 }
776
777 #[test]
778 fn test_undo_entry_stores_original() {
779 let text = "hello\nworld\n";
780 let op = Op::Replace {
781 find: "hello".to_string(),
782 replace: "hi".to_string(),
783 regex: false,
784 case_insensitive: false,
785 };
786 let matcher = Matcher::new(&op).unwrap();
787 let result = apply(text, &op, &matcher, None, 0).unwrap();
788 let undo = result.undo.unwrap();
789 assert_eq!(undo.original_text, text);
790 }
791
792 #[test]
793 fn test_determinism_same_input_same_output() {
794 let text = "foo bar baz\nhello world\nfoo again\n";
795 let op = Op::Replace {
796 find: "foo".to_string(),
797 replace: "qux".to_string(),
798 regex: false,
799 case_insensitive: false,
800 };
801 let matcher = Matcher::new(&op).unwrap();
802 let r1 = apply(text, &op, &matcher, None, 0).unwrap();
803 let r2 = apply(text, &op, &matcher, None, 0).unwrap();
804 assert_eq!(r1.text, r2.text);
805 assert_eq!(r1.changes.len(), r2.changes.len());
806 for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
807 assert_eq!(c1, c2);
808 }
809 }
810
811 #[test]
816 fn test_transform_upper() {
817 let text = "hello world\nfoo bar\n";
818 let op = Op::Transform {
819 find: "hello".to_string(),
820 mode: TransformMode::Upper,
821 regex: false,
822 case_insensitive: false,
823 };
824 let matcher = Matcher::new(&op).unwrap();
825 let result = apply(text, &op, &matcher, None, 0).unwrap();
826 assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
827 assert_eq!(result.changes.len(), 1);
828 assert_eq!(result.changes[0].line, 1);
829 }
830
831 #[test]
832 fn test_transform_lower() {
833 let text = "HELLO WORLD\nFOO BAR\n";
834 let op = Op::Transform {
835 find: "HELLO".to_string(),
836 mode: TransformMode::Lower,
837 regex: false,
838 case_insensitive: false,
839 };
840 let matcher = Matcher::new(&op).unwrap();
841 let result = apply(text, &op, &matcher, None, 0).unwrap();
842 assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
843 assert_eq!(result.changes.len(), 1);
844 }
845
846 #[test]
847 fn test_transform_noop_when_already_target_case() {
848 let text = "hello world\nfoo bar\n";
850 let op = Op::Transform {
851 find: "hello".to_string(),
852 mode: TransformMode::Lower,
853 regex: false,
854 case_insensitive: false,
855 };
856 let matcher = Matcher::new(&op).unwrap();
857 let result = apply(text, &op, &matcher, None, 0).unwrap();
858 assert!(result.text.is_none(), "No text modification expected");
859 assert!(result.changes.is_empty(), "No changes expected");
860 }
861
862 #[test]
863 fn test_transform_title() {
864 let text = "hello world\nfoo bar\n";
865 let op = Op::Transform {
866 find: "hello world".to_string(),
867 mode: TransformMode::Title,
868 regex: false,
869 case_insensitive: false,
870 };
871 let matcher = Matcher::new(&op).unwrap();
872 let result = apply(text, &op, &matcher, None, 0).unwrap();
873 assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
874 assert_eq!(result.changes.len(), 1);
875 }
876
877 #[test]
878 fn test_transform_snake_case() {
879 let text = "let myVariable = 1;\nother line\n";
880 let op = Op::Transform {
881 find: "myVariable".to_string(),
882 mode: TransformMode::SnakeCase,
883 regex: false,
884 case_insensitive: false,
885 };
886 let matcher = Matcher::new(&op).unwrap();
887 let result = apply(text, &op, &matcher, None, 0).unwrap();
888 assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
889 assert_eq!(result.changes.len(), 1);
890 }
891
892 #[test]
893 fn test_transform_camel_case() {
894 let text = "let my_variable = 1;\nother line\n";
895 let op = Op::Transform {
896 find: "my_variable".to_string(),
897 mode: TransformMode::CamelCase,
898 regex: false,
899 case_insensitive: false,
900 };
901 let matcher = Matcher::new(&op).unwrap();
902 let result = apply(text, &op, &matcher, None, 0).unwrap();
903 assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
904 assert_eq!(result.changes.len(), 1);
905 }
906
907 #[test]
908 fn test_transform_upper_multiple_matches_on_line() {
909 let text = "hello and hello again\n";
910 let op = Op::Transform {
911 find: "hello".to_string(),
912 mode: TransformMode::Upper,
913 regex: false,
914 case_insensitive: false,
915 };
916 let matcher = Matcher::new(&op).unwrap();
917 let result = apply(text, &op, &matcher, None, 0).unwrap();
918 assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
919 }
920
921 #[test]
922 fn test_transform_no_match() {
923 let text = "hello world\n";
924 let op = Op::Transform {
925 find: "zzz".to_string(),
926 mode: TransformMode::Upper,
927 regex: false,
928 case_insensitive: false,
929 };
930 let matcher = Matcher::new(&op).unwrap();
931 let result = apply(text, &op, &matcher, None, 0).unwrap();
932 assert!(result.text.is_none());
933 assert!(result.changes.is_empty());
934 }
935
936 #[test]
937 fn test_transform_empty_text() {
938 let text = "";
939 let op = Op::Transform {
940 find: "anything".to_string(),
941 mode: TransformMode::Upper,
942 regex: false,
943 case_insensitive: false,
944 };
945 let matcher = Matcher::new(&op).unwrap();
946 let result = apply(text, &op, &matcher, None, 0).unwrap();
947 assert!(result.text.is_none());
948 assert!(result.changes.is_empty());
949 }
950
951 #[test]
952 fn test_transform_with_regex() {
953 let text = "let fooBar = 1;\nlet bazQux = 2;\n";
954 let op = Op::Transform {
955 find: r"[a-z]+[A-Z]\w*".to_string(),
956 mode: TransformMode::SnakeCase,
957 regex: true,
958 case_insensitive: false,
959 };
960 let matcher = Matcher::new(&op).unwrap();
961 let result = apply(text, &op, &matcher, None, 0).unwrap();
962 let output = result.text.unwrap();
963 assert!(output.contains("foo_bar"));
964 assert!(output.contains("baz_qux"));
965 assert_eq!(result.changes.len(), 2);
966 }
967
968 #[test]
969 fn test_transform_case_insensitive() {
970 let text = "Hello HELLO hello\n";
971 let op = Op::Transform {
972 find: "hello".to_string(),
973 mode: TransformMode::Upper,
974 regex: false,
975 case_insensitive: true,
976 };
977 let matcher = Matcher::new(&op).unwrap();
978 let result = apply(text, &op, &matcher, None, 0).unwrap();
979 assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
980 }
981
982 #[test]
983 fn test_transform_crlf_preserved() {
984 let text = "hello world\r\nfoo bar\r\n";
985 let op = Op::Transform {
986 find: "hello".to_string(),
987 mode: TransformMode::Upper,
988 regex: false,
989 case_insensitive: false,
990 };
991 let matcher = Matcher::new(&op).unwrap();
992 let result = apply(text, &op, &matcher, None, 0).unwrap();
993 assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
994 }
995
996 #[test]
997 fn test_transform_with_line_range() {
998 let text = "hello\nhello\nhello\nhello\n";
999 let op = Op::Transform {
1000 find: "hello".to_string(),
1001 mode: TransformMode::Upper,
1002 regex: false,
1003 case_insensitive: false,
1004 };
1005 let range = Some(LineRange {
1006 start: 2,
1007 end: Some(3),
1008 });
1009 let matcher = Matcher::new(&op).unwrap();
1010 let result = apply(text, &op, &matcher, range, 0).unwrap();
1011 assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
1012 assert_eq!(result.changes.len(), 2);
1013 }
1014
1015 #[test]
1016 fn test_transform_title_with_underscores() {
1017 let text = "my_func_name\n";
1018 let op = Op::Transform {
1019 find: "my_func_name".to_string(),
1020 mode: TransformMode::Title,
1021 regex: false,
1022 case_insensitive: false,
1023 };
1024 let matcher = Matcher::new(&op).unwrap();
1025 let result = apply(text, &op, &matcher, None, 0).unwrap();
1026 assert_eq!(result.text.unwrap(), "My_Func_Name\n");
1028 }
1029
1030 #[test]
1031 fn test_transform_snake_case_from_multi_word() {
1032 let text = "my-kebab-case\n";
1033 let op = Op::Transform {
1034 find: "my-kebab-case".to_string(),
1035 mode: TransformMode::SnakeCase,
1036 regex: false,
1037 case_insensitive: false,
1038 };
1039 let matcher = Matcher::new(&op).unwrap();
1040 let result = apply(text, &op, &matcher, None, 0).unwrap();
1041 assert_eq!(result.text.unwrap(), "my_kebab_case\n");
1042 }
1043
1044 #[test]
1045 fn test_transform_camel_case_from_snake() {
1046 let text = "my_var_name\n";
1047 let op = Op::Transform {
1048 find: "my_var_name".to_string(),
1049 mode: TransformMode::CamelCase,
1050 regex: false,
1051 case_insensitive: false,
1052 };
1053 let matcher = Matcher::new(&op).unwrap();
1054 let result = apply(text, &op, &matcher, None, 0).unwrap();
1055 assert_eq!(result.text.unwrap(), "myVarName\n");
1056 }
1057
1058 #[test]
1059 fn test_transform_camel_case_from_kebab() {
1060 let text = "my-var-name\n";
1061 let op = Op::Transform {
1062 find: "my-var-name".to_string(),
1063 mode: TransformMode::CamelCase,
1064 regex: false,
1065 case_insensitive: false,
1066 };
1067 let matcher = Matcher::new(&op).unwrap();
1068 let result = apply(text, &op, &matcher, None, 0).unwrap();
1069 assert_eq!(result.text.unwrap(), "myVarName\n");
1070 }
1071
1072 #[test]
1077 fn test_surround_basic() {
1078 let text = "hello world\nfoo bar\n";
1079 let op = Op::Surround {
1080 find: "hello".to_string(),
1081 prefix: "<<".to_string(),
1082 suffix: ">>".to_string(),
1083 regex: false,
1084 case_insensitive: false,
1085 };
1086 let matcher = Matcher::new(&op).unwrap();
1087 let result = apply(text, &op, &matcher, None, 0).unwrap();
1088 assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
1089 assert_eq!(result.changes.len(), 1);
1090 assert_eq!(result.changes[0].line, 1);
1091 }
1092
1093 #[test]
1094 fn test_surround_multiple_lines() {
1095 let text = "foo line 1\nbar line 2\nfoo line 3\n";
1096 let op = Op::Surround {
1097 find: "foo".to_string(),
1098 prefix: "[".to_string(),
1099 suffix: "]".to_string(),
1100 regex: false,
1101 case_insensitive: false,
1102 };
1103 let matcher = Matcher::new(&op).unwrap();
1104 let result = apply(text, &op, &matcher, None, 0).unwrap();
1105 assert_eq!(
1106 result.text.unwrap(),
1107 "[foo line 1]\nbar line 2\n[foo line 3]\n"
1108 );
1109 assert_eq!(result.changes.len(), 2);
1110 }
1111
1112 #[test]
1113 fn test_surround_no_match() {
1114 let text = "hello world\n";
1115 let op = Op::Surround {
1116 find: "zzz".to_string(),
1117 prefix: "<".to_string(),
1118 suffix: ">".to_string(),
1119 regex: false,
1120 case_insensitive: false,
1121 };
1122 let matcher = Matcher::new(&op).unwrap();
1123 let result = apply(text, &op, &matcher, None, 0).unwrap();
1124 assert!(result.text.is_none());
1125 assert!(result.changes.is_empty());
1126 }
1127
1128 #[test]
1129 fn test_surround_empty_text() {
1130 let text = "";
1131 let op = Op::Surround {
1132 find: "anything".to_string(),
1133 prefix: "<".to_string(),
1134 suffix: ">".to_string(),
1135 regex: false,
1136 case_insensitive: false,
1137 };
1138 let matcher = Matcher::new(&op).unwrap();
1139 let result = apply(text, &op, &matcher, None, 0).unwrap();
1140 assert!(result.text.is_none());
1141 assert!(result.changes.is_empty());
1142 }
1143
1144 #[test]
1145 fn test_surround_with_regex() {
1146 let text = "fn main() {\n let x = 1;\n}\n";
1147 let op = Op::Surround {
1148 find: r"fn\s+\w+".to_string(),
1149 prefix: "/* ".to_string(),
1150 suffix: " */".to_string(),
1151 regex: true,
1152 case_insensitive: false,
1153 };
1154 let matcher = Matcher::new(&op).unwrap();
1155 let result = apply(text, &op, &matcher, None, 0).unwrap();
1156 assert_eq!(
1157 result.text.unwrap(),
1158 "/* fn main() { */\n let x = 1;\n}\n"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_surround_case_insensitive() {
1164 let text = "Hello world\nhello world\nHELLO world\n";
1165 let op = Op::Surround {
1166 find: "hello".to_string(),
1167 prefix: "(".to_string(),
1168 suffix: ")".to_string(),
1169 regex: false,
1170 case_insensitive: true,
1171 };
1172 let matcher = Matcher::new(&op).unwrap();
1173 let result = apply(text, &op, &matcher, None, 0).unwrap();
1174 let output = result.text.unwrap();
1175 assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
1176 assert_eq!(result.changes.len(), 3);
1177 }
1178
1179 #[test]
1180 fn test_surround_crlf_preserved() {
1181 let text = "hello world\r\nfoo bar\r\n";
1182 let op = Op::Surround {
1183 find: "hello".to_string(),
1184 prefix: "[".to_string(),
1185 suffix: "]".to_string(),
1186 regex: false,
1187 case_insensitive: false,
1188 };
1189 let matcher = Matcher::new(&op).unwrap();
1190 let result = apply(text, &op, &matcher, None, 0).unwrap();
1191 assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
1192 }
1193
1194 #[test]
1195 fn test_surround_with_line_range() {
1196 let text = "foo\nfoo\nfoo\nfoo\n";
1197 let op = Op::Surround {
1198 find: "foo".to_string(),
1199 prefix: "<".to_string(),
1200 suffix: ">".to_string(),
1201 regex: false,
1202 case_insensitive: false,
1203 };
1204 let range = Some(LineRange {
1205 start: 2,
1206 end: Some(3),
1207 });
1208 let matcher = Matcher::new(&op).unwrap();
1209 let result = apply(text, &op, &matcher, range, 0).unwrap();
1210 assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
1211 assert_eq!(result.changes.len(), 2);
1212 }
1213
1214 #[test]
1215 fn test_surround_with_empty_prefix_and_suffix() {
1216 let text = "hello world\n";
1217 let op = Op::Surround {
1218 find: "hello".to_string(),
1219 prefix: String::new(),
1220 suffix: String::new(),
1221 regex: false,
1222 case_insensitive: false,
1223 };
1224 let matcher = Matcher::new(&op).unwrap();
1225 let result = apply(text, &op, &matcher, None, 0).unwrap();
1226 assert!(result.text.is_none());
1228 assert!(result.changes.is_empty());
1229 }
1230
1231 #[test]
1236 fn test_indent_basic() {
1237 let text = "hello\nworld\n";
1238 let op = Op::Indent {
1239 find: "hello".to_string(),
1240 amount: 4,
1241 use_tabs: false,
1242 regex: false,
1243 case_insensitive: false,
1244 };
1245 let matcher = Matcher::new(&op).unwrap();
1246 let result = apply(text, &op, &matcher, None, 0).unwrap();
1247 assert_eq!(result.text.unwrap(), " hello\nworld\n");
1248 assert_eq!(result.changes.len(), 1);
1249 }
1250
1251 #[test]
1252 fn test_indent_multiple_lines() {
1253 let text = "foo line 1\nbar line 2\nfoo line 3\n";
1254 let op = Op::Indent {
1255 find: "foo".to_string(),
1256 amount: 2,
1257 use_tabs: false,
1258 regex: false,
1259 case_insensitive: false,
1260 };
1261 let matcher = Matcher::new(&op).unwrap();
1262 let result = apply(text, &op, &matcher, None, 0).unwrap();
1263 assert_eq!(
1264 result.text.unwrap(),
1265 " foo line 1\nbar line 2\n foo line 3\n"
1266 );
1267 assert_eq!(result.changes.len(), 2);
1268 }
1269
1270 #[test]
1271 fn test_indent_with_tabs() {
1272 let text = "hello\nworld\n";
1273 let op = Op::Indent {
1274 find: "hello".to_string(),
1275 amount: 2,
1276 use_tabs: true,
1277 regex: false,
1278 case_insensitive: false,
1279 };
1280 let matcher = Matcher::new(&op).unwrap();
1281 let result = apply(text, &op, &matcher, None, 0).unwrap();
1282 assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
1283 }
1284
1285 #[test]
1286 fn test_indent_no_match() {
1287 let text = "hello world\n";
1288 let op = Op::Indent {
1289 find: "zzz".to_string(),
1290 amount: 4,
1291 use_tabs: false,
1292 regex: false,
1293 case_insensitive: false,
1294 };
1295 let matcher = Matcher::new(&op).unwrap();
1296 let result = apply(text, &op, &matcher, None, 0).unwrap();
1297 assert!(result.text.is_none());
1298 assert!(result.changes.is_empty());
1299 }
1300
1301 #[test]
1302 fn test_indent_empty_text() {
1303 let text = "";
1304 let op = Op::Indent {
1305 find: "anything".to_string(),
1306 amount: 4,
1307 use_tabs: false,
1308 regex: false,
1309 case_insensitive: false,
1310 };
1311 let matcher = Matcher::new(&op).unwrap();
1312 let result = apply(text, &op, &matcher, None, 0).unwrap();
1313 assert!(result.text.is_none());
1314 assert!(result.changes.is_empty());
1315 }
1316
1317 #[test]
1318 fn test_indent_zero_amount() {
1319 let text = "hello\n";
1320 let op = Op::Indent {
1321 find: "hello".to_string(),
1322 amount: 0,
1323 use_tabs: false,
1324 regex: false,
1325 case_insensitive: false,
1326 };
1327 let matcher = Matcher::new(&op).unwrap();
1328 let result = apply(text, &op, &matcher, None, 0).unwrap();
1329 assert!(result.text.is_none());
1331 assert!(result.changes.is_empty());
1332 }
1333
1334 #[test]
1335 fn test_indent_with_regex() {
1336 let text = "fn main() {\nlet x = 1;\n}\n";
1337 let op = Op::Indent {
1338 find: r"let\s+".to_string(),
1339 amount: 4,
1340 use_tabs: false,
1341 regex: true,
1342 case_insensitive: false,
1343 };
1344 let matcher = Matcher::new(&op).unwrap();
1345 let result = apply(text, &op, &matcher, None, 0).unwrap();
1346 assert_eq!(result.text.unwrap(), "fn main() {\n let x = 1;\n}\n");
1347 assert_eq!(result.changes.len(), 1);
1348 }
1349
1350 #[test]
1351 fn test_indent_case_insensitive() {
1352 let text = "Hello\nhello\nHELLO\n";
1353 let op = Op::Indent {
1354 find: "hello".to_string(),
1355 amount: 2,
1356 use_tabs: false,
1357 regex: false,
1358 case_insensitive: true,
1359 };
1360 let matcher = Matcher::new(&op).unwrap();
1361 let result = apply(text, &op, &matcher, None, 0).unwrap();
1362 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
1363 assert_eq!(result.changes.len(), 3);
1364 }
1365
1366 #[test]
1367 fn test_indent_crlf_preserved() {
1368 let text = "hello\r\nworld\r\n";
1369 let op = Op::Indent {
1370 find: "hello".to_string(),
1371 amount: 4,
1372 use_tabs: false,
1373 regex: false,
1374 case_insensitive: false,
1375 };
1376 let matcher = Matcher::new(&op).unwrap();
1377 let result = apply(text, &op, &matcher, None, 0).unwrap();
1378 assert_eq!(result.text.unwrap(), " hello\r\nworld\r\n");
1379 }
1380
1381 #[test]
1382 fn test_indent_with_line_range() {
1383 let text = "foo\nfoo\nfoo\nfoo\n";
1384 let op = Op::Indent {
1385 find: "foo".to_string(),
1386 amount: 4,
1387 use_tabs: false,
1388 regex: false,
1389 case_insensitive: false,
1390 };
1391 let range = Some(LineRange {
1392 start: 2,
1393 end: Some(3),
1394 });
1395 let matcher = Matcher::new(&op).unwrap();
1396 let result = apply(text, &op, &matcher, range, 0).unwrap();
1397 assert_eq!(result.text.unwrap(), "foo\n foo\n foo\nfoo\n");
1398 assert_eq!(result.changes.len(), 2);
1399 }
1400
1401 #[test]
1406 fn test_dedent_basic() {
1407 let text = " hello\nworld\n";
1408 let op = Op::Dedent {
1409 find: "hello".to_string(),
1410 amount: 4,
1411 use_tabs: false,
1412 regex: false,
1413 case_insensitive: false,
1414 };
1415 let matcher = Matcher::new(&op).unwrap();
1416 let result = apply(text, &op, &matcher, None, 0).unwrap();
1417 assert_eq!(result.text.unwrap(), "hello\nworld\n");
1418 assert_eq!(result.changes.len(), 1);
1419 }
1420
1421 #[test]
1422 fn test_dedent_partial() {
1423 let text = " hello\n";
1425 let op = Op::Dedent {
1426 find: "hello".to_string(),
1427 amount: 4,
1428 use_tabs: false,
1429 regex: false,
1430 case_insensitive: false,
1431 };
1432 let matcher = Matcher::new(&op).unwrap();
1433 let result = apply(text, &op, &matcher, None, 0).unwrap();
1434 assert_eq!(result.text.unwrap(), "hello\n");
1435 }
1436
1437 #[test]
1438 fn test_dedent_no_leading_spaces() {
1439 let text = "hello\n";
1441 let op = Op::Dedent {
1442 find: "hello".to_string(),
1443 amount: 4,
1444 use_tabs: false,
1445 regex: false,
1446 case_insensitive: false,
1447 };
1448 let matcher = Matcher::new(&op).unwrap();
1449 let result = apply(text, &op, &matcher, None, 0).unwrap();
1450 assert!(result.text.is_none());
1452 assert!(result.changes.is_empty());
1453 }
1454
1455 #[test]
1456 fn test_dedent_multiple_lines() {
1457 let text = " foo line 1\n bar line 2\n foo line 3\n";
1458 let op = Op::Dedent {
1459 find: "foo".to_string(),
1460 amount: 4,
1461 use_tabs: false,
1462 regex: false,
1463 case_insensitive: false,
1464 };
1465 let matcher = Matcher::new(&op).unwrap();
1466 let result = apply(text, &op, &matcher, None, 0).unwrap();
1467 assert_eq!(
1468 result.text.unwrap(),
1469 "foo line 1\n bar line 2\nfoo line 3\n"
1470 );
1471 assert_eq!(result.changes.len(), 2);
1472 }
1473
1474 #[test]
1475 fn test_dedent_no_match() {
1476 let text = " hello world\n";
1477 let op = Op::Dedent {
1478 find: "zzz".to_string(),
1479 amount: 4,
1480 use_tabs: false,
1481 regex: false,
1482 case_insensitive: false,
1483 };
1484 let matcher = Matcher::new(&op).unwrap();
1485 let result = apply(text, &op, &matcher, None, 0).unwrap();
1486 assert!(result.text.is_none());
1487 assert!(result.changes.is_empty());
1488 }
1489
1490 #[test]
1491 fn test_dedent_empty_text() {
1492 let text = "";
1493 let op = Op::Dedent {
1494 find: "anything".to_string(),
1495 amount: 4,
1496 use_tabs: false,
1497 regex: false,
1498 case_insensitive: false,
1499 };
1500 let matcher = Matcher::new(&op).unwrap();
1501 let result = apply(text, &op, &matcher, None, 0).unwrap();
1502 assert!(result.text.is_none());
1503 assert!(result.changes.is_empty());
1504 }
1505
1506 #[test]
1507 fn test_dedent_with_regex() {
1508 let text = " let x = 1;\n fn main() {\n";
1509 let op = Op::Dedent {
1510 find: r"let\s+".to_string(),
1511 amount: 4,
1512 use_tabs: false,
1513 regex: true,
1514 case_insensitive: false,
1515 };
1516 let matcher = Matcher::new(&op).unwrap();
1517 let result = apply(text, &op, &matcher, None, 0).unwrap();
1518 assert_eq!(result.text.unwrap(), "let x = 1;\n fn main() {\n");
1519 assert_eq!(result.changes.len(), 1);
1520 }
1521
1522 #[test]
1523 fn test_dedent_case_insensitive() {
1524 let text = " Hello\n hello\n HELLO\n";
1525 let op = Op::Dedent {
1526 find: "hello".to_string(),
1527 amount: 2,
1528 use_tabs: false,
1529 regex: false,
1530 case_insensitive: true,
1531 };
1532 let matcher = Matcher::new(&op).unwrap();
1533 let result = apply(text, &op, &matcher, None, 0).unwrap();
1534 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
1535 assert_eq!(result.changes.len(), 3);
1536 }
1537
1538 #[test]
1539 fn test_dedent_crlf_preserved() {
1540 let text = " hello\r\nworld\r\n";
1541 let op = Op::Dedent {
1542 find: "hello".to_string(),
1543 amount: 4,
1544 use_tabs: false,
1545 regex: false,
1546 case_insensitive: false,
1547 };
1548 let matcher = Matcher::new(&op).unwrap();
1549 let result = apply(text, &op, &matcher, None, 0).unwrap();
1550 assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
1551 }
1552
1553 #[test]
1554 fn test_dedent_with_line_range() {
1555 let text = " foo\n foo\n foo\n foo\n";
1556 let op = Op::Dedent {
1557 find: "foo".to_string(),
1558 amount: 4,
1559 use_tabs: false,
1560 regex: false,
1561 case_insensitive: false,
1562 };
1563 let range = Some(LineRange {
1564 start: 2,
1565 end: Some(3),
1566 });
1567 let matcher = Matcher::new(&op).unwrap();
1568 let result = apply(text, &op, &matcher, range, 0).unwrap();
1569 assert_eq!(result.text.unwrap(), " foo\nfoo\nfoo\n foo\n");
1570 assert_eq!(result.changes.len(), 2);
1571 }
1572
1573 #[test]
1574 fn test_dedent_only_removes_spaces_not_tabs() {
1575 let text = "\t\thello\n";
1577 let op = Op::Dedent {
1578 find: "hello".to_string(),
1579 amount: 4,
1580 use_tabs: false,
1581 regex: false,
1582 case_insensitive: false,
1583 };
1584 let matcher = Matcher::new(&op).unwrap();
1585 let result = apply(text, &op, &matcher, None, 0).unwrap();
1586 assert!(result.text.is_none());
1589 }
1590
1591 #[test]
1596 fn test_indent_then_dedent_roundtrip() {
1597 let original = "hello world\nfoo bar\n";
1598
1599 let indent_op = Op::Indent {
1601 find: "hello".to_string(),
1602 amount: 4,
1603 use_tabs: false,
1604 regex: false,
1605 case_insensitive: false,
1606 };
1607 let indent_matcher = Matcher::new(&indent_op).unwrap();
1608 let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
1609 let indented_text = indented.text.unwrap();
1610 assert_eq!(indented_text, " hello world\nfoo bar\n");
1611
1612 let dedent_op = Op::Dedent {
1614 find: "hello".to_string(),
1615 amount: 4,
1616 use_tabs: false,
1617 regex: false,
1618 case_insensitive: false,
1619 };
1620 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1621 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1622 assert_eq!(dedented.text.unwrap(), original);
1623 }
1624
1625 #[test]
1630 fn test_transform_undo_stores_original() {
1631 let text = "hello world\n";
1632 let op = Op::Transform {
1633 find: "hello".to_string(),
1634 mode: TransformMode::Upper,
1635 regex: false,
1636 case_insensitive: false,
1637 };
1638 let matcher = Matcher::new(&op).unwrap();
1639 let result = apply(text, &op, &matcher, None, 0).unwrap();
1640 assert_eq!(result.undo.unwrap().original_text, text);
1641 }
1642
1643 #[test]
1644 fn test_surround_undo_stores_original() {
1645 let text = "hello world\n";
1646 let op = Op::Surround {
1647 find: "hello".to_string(),
1648 prefix: "<".to_string(),
1649 suffix: ">".to_string(),
1650 regex: false,
1651 case_insensitive: false,
1652 };
1653 let matcher = Matcher::new(&op).unwrap();
1654 let result = apply(text, &op, &matcher, None, 0).unwrap();
1655 assert_eq!(result.undo.unwrap().original_text, text);
1656 }
1657
1658 #[test]
1659 fn test_indent_undo_stores_original() {
1660 let text = "hello world\n";
1661 let op = Op::Indent {
1662 find: "hello".to_string(),
1663 amount: 4,
1664 use_tabs: false,
1665 regex: false,
1666 case_insensitive: false,
1667 };
1668 let matcher = Matcher::new(&op).unwrap();
1669 let result = apply(text, &op, &matcher, None, 0).unwrap();
1670 assert_eq!(result.undo.unwrap().original_text, text);
1671 }
1672
1673 #[test]
1674 fn test_dedent_undo_stores_original() {
1675 let text = " hello world\n";
1676 let op = Op::Dedent {
1677 find: "hello".to_string(),
1678 amount: 4,
1679 use_tabs: false,
1680 regex: false,
1681 case_insensitive: false,
1682 };
1683 let matcher = Matcher::new(&op).unwrap();
1684 let result = apply(text, &op, &matcher, None, 0).unwrap();
1685 assert_eq!(result.undo.unwrap().original_text, text);
1686 }
1687
1688 #[test]
1693 fn test_transform_preserves_line_count() {
1694 let text = "hello\nworld\nfoo\n";
1695 let op = Op::Transform {
1696 find: "hello".to_string(),
1697 mode: TransformMode::Upper,
1698 regex: false,
1699 case_insensitive: false,
1700 };
1701 let matcher = Matcher::new(&op).unwrap();
1702 let result = apply(text, &op, &matcher, None, 0).unwrap();
1703 let output = result.text.unwrap();
1704 assert_eq!(text.lines().count(), output.lines().count());
1705 }
1706
1707 #[test]
1708 fn test_surround_preserves_line_count() {
1709 let text = "hello\nworld\nfoo\n";
1710 let op = Op::Surround {
1711 find: "hello".to_string(),
1712 prefix: "<".to_string(),
1713 suffix: ">".to_string(),
1714 regex: false,
1715 case_insensitive: false,
1716 };
1717 let matcher = Matcher::new(&op).unwrap();
1718 let result = apply(text, &op, &matcher, None, 0).unwrap();
1719 let output = result.text.unwrap();
1720 assert_eq!(text.lines().count(), output.lines().count());
1721 }
1722
1723 #[test]
1724 fn test_indent_preserves_line_count() {
1725 let text = "hello\nworld\nfoo\n";
1726 let op = Op::Indent {
1727 find: "hello".to_string(),
1728 amount: 4,
1729 use_tabs: false,
1730 regex: false,
1731 case_insensitive: false,
1732 };
1733 let matcher = Matcher::new(&op).unwrap();
1734 let result = apply(text, &op, &matcher, None, 0).unwrap();
1735 let output = result.text.unwrap();
1736 assert_eq!(text.lines().count(), output.lines().count());
1737 }
1738
1739 #[test]
1740 fn test_dedent_preserves_line_count() {
1741 let text = " hello\n world\n foo\n";
1742 let op = Op::Dedent {
1743 find: "hello".to_string(),
1744 amount: 4,
1745 use_tabs: false,
1746 regex: false,
1747 case_insensitive: false,
1748 };
1749 let matcher = Matcher::new(&op).unwrap();
1750 let result = apply(text, &op, &matcher, None, 0).unwrap();
1751 let output = result.text.unwrap();
1752 assert_eq!(text.lines().count(), output.lines().count());
1753 }
1754}
1755
1756#[cfg(test)]
1760mod proptests {
1761 use super::*;
1762 use crate::matcher::Matcher;
1763 use crate::operation::Op;
1764 use proptest::prelude::*;
1765
1766 fn arb_multiline_text() -> impl Strategy<Value = String> {
1768 prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
1769 }
1770
1771 fn arb_find_pattern() -> impl Strategy<Value = String> {
1773 "[a-zA-Z0-9]{1,8}"
1774 }
1775
1776 proptest! {
1777 #[test]
1780 fn prop_roundtrip_undo(
1781 text in arb_multiline_text(),
1782 find in arb_find_pattern(),
1783 replace in "[a-zA-Z0-9]{0,8}",
1784 ) {
1785 let op = Op::Replace {
1786 find: find.clone(),
1787 replace: replace.clone(),
1788 regex: false,
1789 case_insensitive: false,
1790 };
1791 let matcher = Matcher::new(&op).unwrap();
1792 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1793
1794 if let Some(undo) = &result.undo {
1795 prop_assert_eq!(&undo.original_text, &text);
1797 }
1798 if result.text.is_none() {
1800 prop_assert!(result.changes.is_empty());
1801 }
1802 }
1803
1804 #[test]
1806 fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
1807 let op = Op::Replace {
1810 find: "\x00\x00NOMATCH\x00\x00".to_string(),
1811 replace: "replacement".to_string(),
1812 regex: false,
1813 case_insensitive: false,
1814 };
1815 let matcher = Matcher::new(&op).unwrap();
1816 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1817 prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
1818 prop_assert!(result.changes.is_empty());
1819 prop_assert!(result.undo.is_none());
1820 }
1821
1822 #[test]
1824 fn prop_deterministic(
1825 text in arb_multiline_text(),
1826 find in arb_find_pattern(),
1827 replace in "[a-zA-Z0-9]{0,8}",
1828 ) {
1829 let op = Op::Replace {
1830 find,
1831 replace,
1832 regex: false,
1833 case_insensitive: false,
1834 };
1835 let matcher = Matcher::new(&op).unwrap();
1836 let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
1837 let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
1838 prop_assert_eq!(&r1.text, &r2.text);
1839 prop_assert_eq!(r1.changes.len(), r2.changes.len());
1840 }
1841
1842 #[test]
1844 fn prop_replace_preserves_line_count(
1845 text in arb_multiline_text(),
1846 find in arb_find_pattern(),
1847 replace in "[a-zA-Z0-9]{0,8}",
1848 ) {
1849 let op = Op::Replace {
1850 find,
1851 replace,
1852 regex: false,
1853 case_insensitive: false,
1854 };
1855 let matcher = Matcher::new(&op).unwrap();
1856 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1857 if let Some(ref output) = result.text {
1858 let input_lines = text.lines().count();
1859 let output_lines = output.lines().count();
1860 prop_assert_eq!(
1861 input_lines,
1862 output_lines,
1863 "Replace should preserve line count: input={} output={}",
1864 input_lines,
1865 output_lines
1866 );
1867 }
1868 }
1869
1870 #[test]
1873 fn prop_indent_dedent_roundtrip(
1874 amount in 1usize..=16,
1875 ) {
1876 let find = "marker".to_string();
1878 let text = "marker line one\nmarker line two\nmarker line three\n";
1879
1880 let indent_op = Op::Indent {
1881 find: find.clone(),
1882 amount,
1883 use_tabs: false,
1884 regex: false,
1885 case_insensitive: false,
1886 };
1887 let indent_matcher = Matcher::new(&indent_op).unwrap();
1888 let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
1889 let indented_text = indented.text.unwrap();
1890
1891 for line in indented_text.lines() {
1893 let leading = line.len() - line.trim_start_matches(' ').len();
1894 prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
1895 }
1896
1897 let dedent_op = Op::Dedent {
1898 find: find.clone(),
1899 amount,
1900 use_tabs: false,
1901 regex: false,
1902 case_insensitive: false,
1903 };
1904 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1905 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1906 prop_assert_eq!(dedented.text.unwrap(), text);
1907 }
1908
1909 #[test]
1912 fn prop_transform_upper_lower_roundtrip(
1913 find in "[a-z]{1,8}",
1914 ) {
1915 let text = format!("prefix {find} suffix\n");
1916
1917 let upper_op = Op::Transform {
1918 find: find.clone(),
1919 mode: crate::operation::TransformMode::Upper,
1920 regex: false,
1921 case_insensitive: false,
1922 };
1923 let upper_matcher = Matcher::new(&upper_op).unwrap();
1924 let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
1925
1926 if let Some(ref upper_text) = uppered.text {
1927 let upper_find = find.to_uppercase();
1928 let lower_op = Op::Transform {
1929 find: upper_find,
1930 mode: crate::operation::TransformMode::Lower,
1931 regex: false,
1932 case_insensitive: false,
1933 };
1934 let lower_matcher = Matcher::new(&lower_op).unwrap();
1935 let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
1936 prop_assert_eq!(lowered.text.unwrap(), text);
1937 }
1938 }
1939
1940 #[test]
1942 fn prop_surround_preserves_line_count(
1943 text in arb_multiline_text(),
1944 find in arb_find_pattern(),
1945 ) {
1946 let op = Op::Surround {
1947 find,
1948 prefix: "<<".to_string(),
1949 suffix: ">>".to_string(),
1950 regex: false,
1951 case_insensitive: false,
1952 };
1953 let matcher = Matcher::new(&op).unwrap();
1954 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1955 if let Some(ref output) = result.text {
1956 let input_lines = text.lines().count();
1957 let output_lines = output.lines().count();
1958 prop_assert_eq!(
1959 input_lines,
1960 output_lines,
1961 "Surround should preserve line count: input={} output={}",
1962 input_lines,
1963 output_lines
1964 );
1965 }
1966 }
1967
1968 #[test]
1970 fn prop_transform_preserves_line_count(
1971 text in arb_multiline_text(),
1972 find in arb_find_pattern(),
1973 ) {
1974 let op = Op::Transform {
1975 find,
1976 mode: crate::operation::TransformMode::Upper,
1977 regex: false,
1978 case_insensitive: false,
1979 };
1980 let matcher = Matcher::new(&op).unwrap();
1981 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1982 if let Some(ref output) = result.text {
1983 let input_lines = text.lines().count();
1984 let output_lines = output.lines().count();
1985 prop_assert_eq!(
1986 input_lines,
1987 output_lines,
1988 "Transform should preserve line count: input={} output={}",
1989 input_lines,
1990 output_lines
1991 );
1992 }
1993 }
1994
1995 #[test]
1997 fn prop_indent_preserves_line_count(
1998 text in arb_multiline_text(),
1999 find in arb_find_pattern(),
2000 amount in 1usize..=16,
2001 ) {
2002 let op = Op::Indent {
2003 find,
2004 amount,
2005 use_tabs: false,
2006 regex: false,
2007 case_insensitive: false,
2008 };
2009 let matcher = Matcher::new(&op).unwrap();
2010 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2011 if let Some(ref output) = result.text {
2012 let input_lines = text.lines().count();
2013 let output_lines = output.lines().count();
2014 prop_assert_eq!(
2015 input_lines,
2016 output_lines,
2017 "Indent should preserve line count: input={} output={}",
2018 input_lines,
2019 output_lines
2020 );
2021 }
2022 }
2023 }
2024}