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);
334 if !result_lines.is_empty() && (text.ends_with('\n') || text.ends_with("\r\n")) {
335 joined.push_str(line_sep);
336 }
337 Some(joined)
338 };
339
340 let undo = if !changes.is_empty() {
341 Some(UndoEntry {
342 original_text: text.to_string(),
343 })
344 } else {
345 None
346 };
347
348 Ok(EngineOutput {
349 text: modified_text,
350 changes,
351 undo,
352 })
353}
354
355fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
357 match matcher {
358 Matcher::Literal { pattern, .. } => {
359 line.replace(pattern.as_str(), &transform_text(pattern, mode))
360 }
361 Matcher::Regex(re) => {
362 let result = re.replace_all(line, |caps: ®ex::Captures| {
363 transform_text(&caps[0], mode)
364 });
365 result.into_owned()
366 }
367 }
368}
369
370fn transform_text(text: &str, mode: TransformMode) -> String {
372 match mode {
373 TransformMode::Upper => text.to_uppercase(),
374 TransformMode::Lower => text.to_lowercase(),
375 TransformMode::Title => {
376 let mut result = String::with_capacity(text.len());
377 let mut capitalize_next = true;
378 for ch in text.chars() {
379 if ch.is_whitespace() || ch == '_' || ch == '-' {
380 result.push(ch);
381 capitalize_next = true;
382 } else if capitalize_next {
383 for upper in ch.to_uppercase() {
384 result.push(upper);
385 }
386 capitalize_next = false;
387 } else {
388 result.push(ch);
389 }
390 }
391 result
392 }
393 TransformMode::SnakeCase => {
394 let mut result = String::with_capacity(text.len() + 4);
395 let mut prev_was_lower = false;
396 for ch in text.chars() {
397 if ch.is_uppercase() {
398 if prev_was_lower {
399 result.push('_');
400 }
401 for lower in ch.to_lowercase() {
402 result.push(lower);
403 }
404 prev_was_lower = false;
405 } else if ch == '-' || ch == ' ' {
406 result.push('_');
407 prev_was_lower = false;
408 } else {
409 result.push(ch);
410 prev_was_lower = ch.is_lowercase();
411 }
412 }
413 result
414 }
415 TransformMode::CamelCase => {
416 let mut result = String::with_capacity(text.len());
417 let mut capitalize_next = false;
418 let mut first = true;
419 for ch in text.chars() {
420 if ch == '_' || ch == '-' || ch == ' ' {
421 capitalize_next = true;
422 } else if capitalize_next {
423 for upper in ch.to_uppercase() {
424 result.push(upper);
425 }
426 capitalize_next = false;
427 } else if first {
428 for lower in ch.to_lowercase() {
429 result.push(lower);
430 }
431 first = false;
432 } else {
433 result.push(ch);
434 first = false;
435 }
436 }
437 result
438 }
439 }
440}
441
442fn dedent_line(line: &str, amount: usize, use_tabs: bool) -> String {
445 let ch = if use_tabs { '\t' } else { ' ' };
446 let leading = line.len() - line.trim_start_matches(ch).len();
447 let remove = leading.min(amount);
448 line[remove..].to_string()
449}
450
451fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
452 let start = idx.saturating_sub(context_lines);
453 let end = (idx + context_lines + 1).min(lines.len());
454
455 let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
456 let after = if idx + 1 < end {
457 lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
458 } else {
459 vec![]
460 };
461
462 ChangeContext { before, after }
463}
464
465pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
467 OpResult {
468 operation_index,
469 files: if changes.is_empty() {
470 vec![]
471 } else {
472 vec![FileChanges {
473 path: path.to_string(),
474 changes,
475 }]
476 },
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::matcher::Matcher;
484
485 #[test]
486 fn test_simple_replace() {
487 let text = "hello world\nfoo bar\nhello again\n";
488 let op = Op::Replace {
489 find: "hello".to_string(),
490 replace: "hi".to_string(),
491 regex: false,
492 case_insensitive: false,
493 };
494 let matcher = Matcher::new(&op).unwrap();
495 let result = apply(text, &op, &matcher, None, 2).unwrap();
496 assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
497 assert_eq!(result.changes.len(), 2);
498 }
499
500 #[test]
501 fn test_delete_lines() {
502 let text = "keep\ndelete me\nkeep too\n";
503 let op = Op::Delete {
504 find: "delete".to_string(),
505 regex: false,
506 case_insensitive: false,
507 };
508 let matcher = Matcher::new(&op).unwrap();
509 let result = apply(text, &op, &matcher, None, 0).unwrap();
510 assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
511 }
512
513 #[test]
514 fn test_no_changes() {
515 let text = "nothing matches here\n";
516 let op = Op::Replace {
517 find: "zzz".to_string(),
518 replace: "aaa".to_string(),
519 regex: false,
520 case_insensitive: false,
521 };
522 let matcher = Matcher::new(&op).unwrap();
523 let result = apply(text, &op, &matcher, None, 0).unwrap();
524 assert!(result.text.is_none());
525 assert!(result.changes.is_empty());
526 }
527
528 #[test]
529 fn test_line_range() {
530 let text = "line1\nline2\nline3\nline4\n";
531 let op = Op::Replace {
532 find: "line".to_string(),
533 replace: "row".to_string(),
534 regex: false,
535 case_insensitive: false,
536 };
537 let range = Some(LineRange {
538 start: 2,
539 end: Some(3),
540 });
541 let matcher = Matcher::new(&op).unwrap();
542 let result = apply(text, &op, &matcher, range, 0).unwrap();
543 assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
544 }
545
546 #[test]
551 fn test_crlf_replace_preserves_crlf() {
552 let text = "hello world\r\nfoo bar\r\nhello again\r\n";
553 let op = Op::Replace {
554 find: "hello".to_string(),
555 replace: "hi".to_string(),
556 regex: false,
557 case_insensitive: false,
558 };
559 let matcher = Matcher::new(&op).unwrap();
560 let result = apply(text, &op, &matcher, None, 0).unwrap();
561 assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
562 }
563
564 #[test]
565 fn test_crlf_delete_preserves_crlf() {
566 let text = "keep\r\ndelete me\r\nkeep too\r\n";
567 let op = Op::Delete {
568 find: "delete".to_string(),
569 regex: false,
570 case_insensitive: false,
571 };
572 let matcher = Matcher::new(&op).unwrap();
573 let result = apply(text, &op, &matcher, None, 0).unwrap();
574 assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
575 }
576
577 #[test]
578 fn test_crlf_no_trailing_newline() {
579 let text = "hello world\r\nfoo bar";
580 let op = Op::Replace {
581 find: "hello".to_string(),
582 replace: "hi".to_string(),
583 regex: false,
584 case_insensitive: false,
585 };
586 let matcher = Matcher::new(&op).unwrap();
587 let result = apply(text, &op, &matcher, None, 0).unwrap();
588 let output = result.text.unwrap();
589 assert_eq!(output, "hi world\r\nfoo bar");
590 assert!(!output.ends_with("\r\n"));
592 }
593
594 #[test]
595 fn test_uses_crlf_detection() {
596 assert!(uses_crlf("a\r\nb\r\n"));
597 assert!(uses_crlf("a\r\n"));
598 assert!(!uses_crlf("a\nb\n"));
599 assert!(!uses_crlf("no newline at all"));
600 assert!(!uses_crlf(""));
601 }
602
603 #[test]
608 fn test_empty_input_text() {
609 let text = "";
610 let op = Op::Replace {
611 find: "anything".to_string(),
612 replace: "something".to_string(),
613 regex: false,
614 case_insensitive: false,
615 };
616 let matcher = Matcher::new(&op).unwrap();
617 let result = apply(text, &op, &matcher, None, 0).unwrap();
618 assert!(result.text.is_none());
619 assert!(result.changes.is_empty());
620 }
621
622 #[test]
623 fn test_single_line_no_trailing_newline() {
624 let text = "hello world";
625 let op = Op::Replace {
626 find: "hello".to_string(),
627 replace: "hi".to_string(),
628 regex: false,
629 case_insensitive: false,
630 };
631 let matcher = Matcher::new(&op).unwrap();
632 let result = apply(text, &op, &matcher, None, 0).unwrap();
633 let output = result.text.unwrap();
634 assert_eq!(output, "hi world");
635 assert!(!output.ends_with('\n'));
637 }
638
639 #[test]
640 fn test_whitespace_only_lines() {
641 let text = " \n\t\n \t \n";
642 let op = Op::Replace {
643 find: "\t".to_string(),
644 replace: "TAB".to_string(),
645 regex: false,
646 case_insensitive: false,
647 };
648 let matcher = Matcher::new(&op).unwrap();
649 let result = apply(text, &op, &matcher, None, 0).unwrap();
650 let output = result.text.unwrap();
651 assert!(output.contains("TAB"));
652 assert_eq!(result.changes.len(), 2); }
654
655 #[test]
656 fn test_very_long_line() {
657 let long_word = "x".repeat(100_000);
658 let text = format!("before\n{long_word}\nafter\n");
659 let op = Op::Replace {
660 find: "x".to_string(),
661 replace: "y".to_string(),
662 regex: false,
663 case_insensitive: false,
664 };
665 let matcher = Matcher::new(&op).unwrap();
666 let result = apply(&text, &op, &matcher, None, 0).unwrap();
667 let output = result.text.unwrap();
668 let expected_long = "y".repeat(100_000);
669 assert!(output.contains(&expected_long));
670 }
671
672 #[test]
673 fn test_unicode_emoji() {
674 let text = "hello world\n";
675 let op = Op::Replace {
676 find: "world".to_string(),
677 replace: "\u{1F30D}".to_string(), regex: false,
679 case_insensitive: false,
680 };
681 let matcher = Matcher::new(&op).unwrap();
682 let result = apply(text, &op, &matcher, None, 0).unwrap();
683 assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
684 }
685
686 #[test]
687 fn test_unicode_cjk() {
688 let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; let op = Op::Replace {
690 find: "\u{4E16}\u{754C}".to_string(), replace: "\u{5730}\u{7403}".to_string(), regex: false,
693 case_insensitive: false,
694 };
695 let matcher = Matcher::new(&op).unwrap();
696 let result = apply(text, &op, &matcher, None, 0).unwrap();
697 assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
698 }
699
700 #[test]
701 fn test_unicode_combining_characters() {
702 let text = "caf\u{0065}\u{0301}\n";
704 let op = Op::Replace {
705 find: "caf\u{0065}\u{0301}".to_string(),
706 replace: "coffee".to_string(),
707 regex: false,
708 case_insensitive: false,
709 };
710 let matcher = Matcher::new(&op).unwrap();
711 let result = apply(text, &op, &matcher, None, 0).unwrap();
712 assert_eq!(result.text.unwrap(), "coffee\n");
713 }
714
715 #[test]
716 fn test_regex_special_chars_in_literal_mode() {
717 let text = "price is $10.00 (USD)\n";
719 let op = Op::Replace {
720 find: "$10.00".to_string(),
721 replace: "$20.00".to_string(),
722 regex: false,
723 case_insensitive: false,
724 };
725 let matcher = Matcher::new(&op).unwrap();
726 let result = apply(text, &op, &matcher, None, 0).unwrap();
727 assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
728 }
729
730 #[test]
731 fn test_overlapping_matches_in_single_line() {
732 let text = "aaa\n";
734 let op = Op::Replace {
735 find: "aa".to_string(),
736 replace: "b".to_string(),
737 regex: false,
738 case_insensitive: false,
739 };
740 let matcher = Matcher::new(&op).unwrap();
741 let result = apply(text, &op, &matcher, None, 0).unwrap();
742 assert_eq!(result.text.unwrap(), "ba\n");
744 }
745
746 #[test]
747 fn test_replace_line_count_preserved() {
748 let text = "line1\nline2\nline3\nline4\nline5\n";
749 let input_line_count = text.lines().count();
750 let op = Op::Replace {
751 find: "line".to_string(),
752 replace: "row".to_string(),
753 regex: false,
754 case_insensitive: false,
755 };
756 let matcher = Matcher::new(&op).unwrap();
757 let result = apply(text, &op, &matcher, None, 0).unwrap();
758 let output = result.text.unwrap();
759 let output_line_count = output.lines().count();
760 assert_eq!(input_line_count, output_line_count);
761 }
762
763 #[test]
764 fn test_replace_preserves_empty_result_on_non_match() {
765 let text = "alpha\nbeta\ngamma\n";
767 let op = Op::Replace {
768 find: "zzzzzz".to_string(),
769 replace: "y".to_string(),
770 regex: false,
771 case_insensitive: false,
772 };
773 let matcher = Matcher::new(&op).unwrap();
774 let result = apply(text, &op, &matcher, None, 0).unwrap();
775 assert!(result.text.is_none());
776 assert!(result.undo.is_none());
777 }
778
779 #[test]
780 fn test_undo_entry_stores_original() {
781 let text = "hello\nworld\n";
782 let op = Op::Replace {
783 find: "hello".to_string(),
784 replace: "hi".to_string(),
785 regex: false,
786 case_insensitive: false,
787 };
788 let matcher = Matcher::new(&op).unwrap();
789 let result = apply(text, &op, &matcher, None, 0).unwrap();
790 let undo = result.undo.unwrap();
791 assert_eq!(undo.original_text, text);
792 }
793
794 #[test]
795 fn test_determinism_same_input_same_output() {
796 let text = "foo bar baz\nhello world\nfoo again\n";
797 let op = Op::Replace {
798 find: "foo".to_string(),
799 replace: "qux".to_string(),
800 regex: false,
801 case_insensitive: false,
802 };
803 let matcher = Matcher::new(&op).unwrap();
804 let r1 = apply(text, &op, &matcher, None, 0).unwrap();
805 let r2 = apply(text, &op, &matcher, None, 0).unwrap();
806 assert_eq!(r1.text, r2.text);
807 assert_eq!(r1.changes.len(), r2.changes.len());
808 for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
809 assert_eq!(c1, c2);
810 }
811 }
812
813 #[test]
818 fn test_transform_upper() {
819 let text = "hello world\nfoo bar\n";
820 let op = Op::Transform {
821 find: "hello".to_string(),
822 mode: TransformMode::Upper,
823 regex: false,
824 case_insensitive: false,
825 };
826 let matcher = Matcher::new(&op).unwrap();
827 let result = apply(text, &op, &matcher, None, 0).unwrap();
828 assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
829 assert_eq!(result.changes.len(), 1);
830 assert_eq!(result.changes[0].line, 1);
831 }
832
833 #[test]
834 fn test_transform_lower() {
835 let text = "HELLO WORLD\nFOO BAR\n";
836 let op = Op::Transform {
837 find: "HELLO".to_string(),
838 mode: TransformMode::Lower,
839 regex: false,
840 case_insensitive: false,
841 };
842 let matcher = Matcher::new(&op).unwrap();
843 let result = apply(text, &op, &matcher, None, 0).unwrap();
844 assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
845 assert_eq!(result.changes.len(), 1);
846 }
847
848 #[test]
849 fn test_transform_noop_when_already_target_case() {
850 let text = "hello world\nfoo bar\n";
852 let op = Op::Transform {
853 find: "hello".to_string(),
854 mode: TransformMode::Lower,
855 regex: false,
856 case_insensitive: false,
857 };
858 let matcher = Matcher::new(&op).unwrap();
859 let result = apply(text, &op, &matcher, None, 0).unwrap();
860 assert!(result.text.is_none(), "No text modification expected");
861 assert!(result.changes.is_empty(), "No changes expected");
862 }
863
864 #[test]
865 fn test_transform_title() {
866 let text = "hello world\nfoo bar\n";
867 let op = Op::Transform {
868 find: "hello world".to_string(),
869 mode: TransformMode::Title,
870 regex: false,
871 case_insensitive: false,
872 };
873 let matcher = Matcher::new(&op).unwrap();
874 let result = apply(text, &op, &matcher, None, 0).unwrap();
875 assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
876 assert_eq!(result.changes.len(), 1);
877 }
878
879 #[test]
880 fn test_transform_snake_case() {
881 let text = "let myVariable = 1;\nother line\n";
882 let op = Op::Transform {
883 find: "myVariable".to_string(),
884 mode: TransformMode::SnakeCase,
885 regex: false,
886 case_insensitive: false,
887 };
888 let matcher = Matcher::new(&op).unwrap();
889 let result = apply(text, &op, &matcher, None, 0).unwrap();
890 assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
891 assert_eq!(result.changes.len(), 1);
892 }
893
894 #[test]
895 fn test_transform_camel_case() {
896 let text = "let my_variable = 1;\nother line\n";
897 let op = Op::Transform {
898 find: "my_variable".to_string(),
899 mode: TransformMode::CamelCase,
900 regex: false,
901 case_insensitive: false,
902 };
903 let matcher = Matcher::new(&op).unwrap();
904 let result = apply(text, &op, &matcher, None, 0).unwrap();
905 assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
906 assert_eq!(result.changes.len(), 1);
907 }
908
909 #[test]
910 fn test_transform_upper_multiple_matches_on_line() {
911 let text = "hello and hello again\n";
912 let op = Op::Transform {
913 find: "hello".to_string(),
914 mode: TransformMode::Upper,
915 regex: false,
916 case_insensitive: false,
917 };
918 let matcher = Matcher::new(&op).unwrap();
919 let result = apply(text, &op, &matcher, None, 0).unwrap();
920 assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
921 }
922
923 #[test]
924 fn test_transform_no_match() {
925 let text = "hello world\n";
926 let op = Op::Transform {
927 find: "zzz".to_string(),
928 mode: TransformMode::Upper,
929 regex: false,
930 case_insensitive: false,
931 };
932 let matcher = Matcher::new(&op).unwrap();
933 let result = apply(text, &op, &matcher, None, 0).unwrap();
934 assert!(result.text.is_none());
935 assert!(result.changes.is_empty());
936 }
937
938 #[test]
939 fn test_transform_empty_text() {
940 let text = "";
941 let op = Op::Transform {
942 find: "anything".to_string(),
943 mode: TransformMode::Upper,
944 regex: false,
945 case_insensitive: false,
946 };
947 let matcher = Matcher::new(&op).unwrap();
948 let result = apply(text, &op, &matcher, None, 0).unwrap();
949 assert!(result.text.is_none());
950 assert!(result.changes.is_empty());
951 }
952
953 #[test]
954 fn test_transform_with_regex() {
955 let text = "let fooBar = 1;\nlet bazQux = 2;\n";
956 let op = Op::Transform {
957 find: r"[a-z]+[A-Z]\w*".to_string(),
958 mode: TransformMode::SnakeCase,
959 regex: true,
960 case_insensitive: false,
961 };
962 let matcher = Matcher::new(&op).unwrap();
963 let result = apply(text, &op, &matcher, None, 0).unwrap();
964 let output = result.text.unwrap();
965 assert!(output.contains("foo_bar"));
966 assert!(output.contains("baz_qux"));
967 assert_eq!(result.changes.len(), 2);
968 }
969
970 #[test]
971 fn test_transform_case_insensitive() {
972 let text = "Hello HELLO hello\n";
973 let op = Op::Transform {
974 find: "hello".to_string(),
975 mode: TransformMode::Upper,
976 regex: false,
977 case_insensitive: true,
978 };
979 let matcher = Matcher::new(&op).unwrap();
980 let result = apply(text, &op, &matcher, None, 0).unwrap();
981 assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
982 }
983
984 #[test]
985 fn test_transform_crlf_preserved() {
986 let text = "hello world\r\nfoo bar\r\n";
987 let op = Op::Transform {
988 find: "hello".to_string(),
989 mode: TransformMode::Upper,
990 regex: false,
991 case_insensitive: false,
992 };
993 let matcher = Matcher::new(&op).unwrap();
994 let result = apply(text, &op, &matcher, None, 0).unwrap();
995 assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
996 }
997
998 #[test]
999 fn test_transform_with_line_range() {
1000 let text = "hello\nhello\nhello\nhello\n";
1001 let op = Op::Transform {
1002 find: "hello".to_string(),
1003 mode: TransformMode::Upper,
1004 regex: false,
1005 case_insensitive: false,
1006 };
1007 let range = Some(LineRange {
1008 start: 2,
1009 end: Some(3),
1010 });
1011 let matcher = Matcher::new(&op).unwrap();
1012 let result = apply(text, &op, &matcher, range, 0).unwrap();
1013 assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
1014 assert_eq!(result.changes.len(), 2);
1015 }
1016
1017 #[test]
1018 fn test_transform_title_with_underscores() {
1019 let text = "my_func_name\n";
1020 let op = Op::Transform {
1021 find: "my_func_name".to_string(),
1022 mode: TransformMode::Title,
1023 regex: false,
1024 case_insensitive: false,
1025 };
1026 let matcher = Matcher::new(&op).unwrap();
1027 let result = apply(text, &op, &matcher, None, 0).unwrap();
1028 assert_eq!(result.text.unwrap(), "My_Func_Name\n");
1030 }
1031
1032 #[test]
1033 fn test_transform_snake_case_from_multi_word() {
1034 let text = "my-kebab-case\n";
1035 let op = Op::Transform {
1036 find: "my-kebab-case".to_string(),
1037 mode: TransformMode::SnakeCase,
1038 regex: false,
1039 case_insensitive: false,
1040 };
1041 let matcher = Matcher::new(&op).unwrap();
1042 let result = apply(text, &op, &matcher, None, 0).unwrap();
1043 assert_eq!(result.text.unwrap(), "my_kebab_case\n");
1044 }
1045
1046 #[test]
1047 fn test_transform_camel_case_from_snake() {
1048 let text = "my_var_name\n";
1049 let op = Op::Transform {
1050 find: "my_var_name".to_string(),
1051 mode: TransformMode::CamelCase,
1052 regex: false,
1053 case_insensitive: false,
1054 };
1055 let matcher = Matcher::new(&op).unwrap();
1056 let result = apply(text, &op, &matcher, None, 0).unwrap();
1057 assert_eq!(result.text.unwrap(), "myVarName\n");
1058 }
1059
1060 #[test]
1061 fn test_transform_camel_case_from_kebab() {
1062 let text = "my-var-name\n";
1063 let op = Op::Transform {
1064 find: "my-var-name".to_string(),
1065 mode: TransformMode::CamelCase,
1066 regex: false,
1067 case_insensitive: false,
1068 };
1069 let matcher = Matcher::new(&op).unwrap();
1070 let result = apply(text, &op, &matcher, None, 0).unwrap();
1071 assert_eq!(result.text.unwrap(), "myVarName\n");
1072 }
1073
1074 #[test]
1079 fn test_surround_basic() {
1080 let text = "hello world\nfoo bar\n";
1081 let op = Op::Surround {
1082 find: "hello".to_string(),
1083 prefix: "<<".to_string(),
1084 suffix: ">>".to_string(),
1085 regex: false,
1086 case_insensitive: false,
1087 };
1088 let matcher = Matcher::new(&op).unwrap();
1089 let result = apply(text, &op, &matcher, None, 0).unwrap();
1090 assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
1091 assert_eq!(result.changes.len(), 1);
1092 assert_eq!(result.changes[0].line, 1);
1093 }
1094
1095 #[test]
1096 fn test_surround_multiple_lines() {
1097 let text = "foo line 1\nbar line 2\nfoo line 3\n";
1098 let op = Op::Surround {
1099 find: "foo".to_string(),
1100 prefix: "[".to_string(),
1101 suffix: "]".to_string(),
1102 regex: false,
1103 case_insensitive: false,
1104 };
1105 let matcher = Matcher::new(&op).unwrap();
1106 let result = apply(text, &op, &matcher, None, 0).unwrap();
1107 assert_eq!(
1108 result.text.unwrap(),
1109 "[foo line 1]\nbar line 2\n[foo line 3]\n"
1110 );
1111 assert_eq!(result.changes.len(), 2);
1112 }
1113
1114 #[test]
1115 fn test_surround_no_match() {
1116 let text = "hello world\n";
1117 let op = Op::Surround {
1118 find: "zzz".to_string(),
1119 prefix: "<".to_string(),
1120 suffix: ">".to_string(),
1121 regex: false,
1122 case_insensitive: false,
1123 };
1124 let matcher = Matcher::new(&op).unwrap();
1125 let result = apply(text, &op, &matcher, None, 0).unwrap();
1126 assert!(result.text.is_none());
1127 assert!(result.changes.is_empty());
1128 }
1129
1130 #[test]
1131 fn test_surround_empty_text() {
1132 let text = "";
1133 let op = Op::Surround {
1134 find: "anything".to_string(),
1135 prefix: "<".to_string(),
1136 suffix: ">".to_string(),
1137 regex: false,
1138 case_insensitive: false,
1139 };
1140 let matcher = Matcher::new(&op).unwrap();
1141 let result = apply(text, &op, &matcher, None, 0).unwrap();
1142 assert!(result.text.is_none());
1143 assert!(result.changes.is_empty());
1144 }
1145
1146 #[test]
1147 fn test_surround_with_regex() {
1148 let text = "fn main() {\n let x = 1;\n}\n";
1149 let op = Op::Surround {
1150 find: r"fn\s+\w+".to_string(),
1151 prefix: "/* ".to_string(),
1152 suffix: " */".to_string(),
1153 regex: true,
1154 case_insensitive: false,
1155 };
1156 let matcher = Matcher::new(&op).unwrap();
1157 let result = apply(text, &op, &matcher, None, 0).unwrap();
1158 assert_eq!(
1159 result.text.unwrap(),
1160 "/* fn main() { */\n let x = 1;\n}\n"
1161 );
1162 }
1163
1164 #[test]
1165 fn test_surround_case_insensitive() {
1166 let text = "Hello world\nhello world\nHELLO world\n";
1167 let op = Op::Surround {
1168 find: "hello".to_string(),
1169 prefix: "(".to_string(),
1170 suffix: ")".to_string(),
1171 regex: false,
1172 case_insensitive: true,
1173 };
1174 let matcher = Matcher::new(&op).unwrap();
1175 let result = apply(text, &op, &matcher, None, 0).unwrap();
1176 let output = result.text.unwrap();
1177 assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
1178 assert_eq!(result.changes.len(), 3);
1179 }
1180
1181 #[test]
1182 fn test_surround_crlf_preserved() {
1183 let text = "hello world\r\nfoo bar\r\n";
1184 let op = Op::Surround {
1185 find: "hello".to_string(),
1186 prefix: "[".to_string(),
1187 suffix: "]".to_string(),
1188 regex: false,
1189 case_insensitive: false,
1190 };
1191 let matcher = Matcher::new(&op).unwrap();
1192 let result = apply(text, &op, &matcher, None, 0).unwrap();
1193 assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
1194 }
1195
1196 #[test]
1197 fn test_surround_with_line_range() {
1198 let text = "foo\nfoo\nfoo\nfoo\n";
1199 let op = Op::Surround {
1200 find: "foo".to_string(),
1201 prefix: "<".to_string(),
1202 suffix: ">".to_string(),
1203 regex: false,
1204 case_insensitive: false,
1205 };
1206 let range = Some(LineRange {
1207 start: 2,
1208 end: Some(3),
1209 });
1210 let matcher = Matcher::new(&op).unwrap();
1211 let result = apply(text, &op, &matcher, range, 0).unwrap();
1212 assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
1213 assert_eq!(result.changes.len(), 2);
1214 }
1215
1216 #[test]
1217 fn test_surround_with_empty_prefix_and_suffix() {
1218 let text = "hello world\n";
1219 let op = Op::Surround {
1220 find: "hello".to_string(),
1221 prefix: String::new(),
1222 suffix: String::new(),
1223 regex: false,
1224 case_insensitive: false,
1225 };
1226 let matcher = Matcher::new(&op).unwrap();
1227 let result = apply(text, &op, &matcher, None, 0).unwrap();
1228 assert!(result.text.is_none());
1230 assert!(result.changes.is_empty());
1231 }
1232
1233 #[test]
1238 fn test_indent_basic() {
1239 let text = "hello\nworld\n";
1240 let op = Op::Indent {
1241 find: "hello".to_string(),
1242 amount: 4,
1243 use_tabs: false,
1244 regex: false,
1245 case_insensitive: false,
1246 };
1247 let matcher = Matcher::new(&op).unwrap();
1248 let result = apply(text, &op, &matcher, None, 0).unwrap();
1249 assert_eq!(result.text.unwrap(), " hello\nworld\n");
1250 assert_eq!(result.changes.len(), 1);
1251 }
1252
1253 #[test]
1254 fn test_indent_multiple_lines() {
1255 let text = "foo line 1\nbar line 2\nfoo line 3\n";
1256 let op = Op::Indent {
1257 find: "foo".to_string(),
1258 amount: 2,
1259 use_tabs: false,
1260 regex: false,
1261 case_insensitive: false,
1262 };
1263 let matcher = Matcher::new(&op).unwrap();
1264 let result = apply(text, &op, &matcher, None, 0).unwrap();
1265 assert_eq!(
1266 result.text.unwrap(),
1267 " foo line 1\nbar line 2\n foo line 3\n"
1268 );
1269 assert_eq!(result.changes.len(), 2);
1270 }
1271
1272 #[test]
1273 fn test_indent_with_tabs() {
1274 let text = "hello\nworld\n";
1275 let op = Op::Indent {
1276 find: "hello".to_string(),
1277 amount: 2,
1278 use_tabs: true,
1279 regex: false,
1280 case_insensitive: false,
1281 };
1282 let matcher = Matcher::new(&op).unwrap();
1283 let result = apply(text, &op, &matcher, None, 0).unwrap();
1284 assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
1285 }
1286
1287 #[test]
1288 fn test_indent_no_match() {
1289 let text = "hello world\n";
1290 let op = Op::Indent {
1291 find: "zzz".to_string(),
1292 amount: 4,
1293 use_tabs: false,
1294 regex: false,
1295 case_insensitive: false,
1296 };
1297 let matcher = Matcher::new(&op).unwrap();
1298 let result = apply(text, &op, &matcher, None, 0).unwrap();
1299 assert!(result.text.is_none());
1300 assert!(result.changes.is_empty());
1301 }
1302
1303 #[test]
1304 fn test_indent_empty_text() {
1305 let text = "";
1306 let op = Op::Indent {
1307 find: "anything".to_string(),
1308 amount: 4,
1309 use_tabs: false,
1310 regex: false,
1311 case_insensitive: false,
1312 };
1313 let matcher = Matcher::new(&op).unwrap();
1314 let result = apply(text, &op, &matcher, None, 0).unwrap();
1315 assert!(result.text.is_none());
1316 assert!(result.changes.is_empty());
1317 }
1318
1319 #[test]
1320 fn test_indent_zero_amount() {
1321 let text = "hello\n";
1322 let op = Op::Indent {
1323 find: "hello".to_string(),
1324 amount: 0,
1325 use_tabs: false,
1326 regex: false,
1327 case_insensitive: false,
1328 };
1329 let matcher = Matcher::new(&op).unwrap();
1330 let result = apply(text, &op, &matcher, None, 0).unwrap();
1331 assert!(result.text.is_none());
1333 assert!(result.changes.is_empty());
1334 }
1335
1336 #[test]
1337 fn test_indent_with_regex() {
1338 let text = "fn main() {\nlet x = 1;\n}\n";
1339 let op = Op::Indent {
1340 find: r"let\s+".to_string(),
1341 amount: 4,
1342 use_tabs: false,
1343 regex: true,
1344 case_insensitive: false,
1345 };
1346 let matcher = Matcher::new(&op).unwrap();
1347 let result = apply(text, &op, &matcher, None, 0).unwrap();
1348 assert_eq!(result.text.unwrap(), "fn main() {\n let x = 1;\n}\n");
1349 assert_eq!(result.changes.len(), 1);
1350 }
1351
1352 #[test]
1353 fn test_indent_case_insensitive() {
1354 let text = "Hello\nhello\nHELLO\n";
1355 let op = Op::Indent {
1356 find: "hello".to_string(),
1357 amount: 2,
1358 use_tabs: false,
1359 regex: false,
1360 case_insensitive: true,
1361 };
1362 let matcher = Matcher::new(&op).unwrap();
1363 let result = apply(text, &op, &matcher, None, 0).unwrap();
1364 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
1365 assert_eq!(result.changes.len(), 3);
1366 }
1367
1368 #[test]
1369 fn test_indent_crlf_preserved() {
1370 let text = "hello\r\nworld\r\n";
1371 let op = Op::Indent {
1372 find: "hello".to_string(),
1373 amount: 4,
1374 use_tabs: false,
1375 regex: false,
1376 case_insensitive: false,
1377 };
1378 let matcher = Matcher::new(&op).unwrap();
1379 let result = apply(text, &op, &matcher, None, 0).unwrap();
1380 assert_eq!(result.text.unwrap(), " hello\r\nworld\r\n");
1381 }
1382
1383 #[test]
1384 fn test_indent_with_line_range() {
1385 let text = "foo\nfoo\nfoo\nfoo\n";
1386 let op = Op::Indent {
1387 find: "foo".to_string(),
1388 amount: 4,
1389 use_tabs: false,
1390 regex: false,
1391 case_insensitive: false,
1392 };
1393 let range = Some(LineRange {
1394 start: 2,
1395 end: Some(3),
1396 });
1397 let matcher = Matcher::new(&op).unwrap();
1398 let result = apply(text, &op, &matcher, range, 0).unwrap();
1399 assert_eq!(result.text.unwrap(), "foo\n foo\n foo\nfoo\n");
1400 assert_eq!(result.changes.len(), 2);
1401 }
1402
1403 #[test]
1408 fn test_dedent_basic() {
1409 let text = " hello\nworld\n";
1410 let op = Op::Dedent {
1411 find: "hello".to_string(),
1412 amount: 4,
1413 use_tabs: false,
1414 regex: false,
1415 case_insensitive: false,
1416 };
1417 let matcher = Matcher::new(&op).unwrap();
1418 let result = apply(text, &op, &matcher, None, 0).unwrap();
1419 assert_eq!(result.text.unwrap(), "hello\nworld\n");
1420 assert_eq!(result.changes.len(), 1);
1421 }
1422
1423 #[test]
1424 fn test_dedent_partial() {
1425 let text = " hello\n";
1427 let op = Op::Dedent {
1428 find: "hello".to_string(),
1429 amount: 4,
1430 use_tabs: false,
1431 regex: false,
1432 case_insensitive: false,
1433 };
1434 let matcher = Matcher::new(&op).unwrap();
1435 let result = apply(text, &op, &matcher, None, 0).unwrap();
1436 assert_eq!(result.text.unwrap(), "hello\n");
1437 }
1438
1439 #[test]
1440 fn test_dedent_no_leading_spaces() {
1441 let text = "hello\n";
1443 let op = Op::Dedent {
1444 find: "hello".to_string(),
1445 amount: 4,
1446 use_tabs: false,
1447 regex: false,
1448 case_insensitive: false,
1449 };
1450 let matcher = Matcher::new(&op).unwrap();
1451 let result = apply(text, &op, &matcher, None, 0).unwrap();
1452 assert!(result.text.is_none());
1454 assert!(result.changes.is_empty());
1455 }
1456
1457 #[test]
1458 fn test_dedent_multiple_lines() {
1459 let text = " foo line 1\n bar line 2\n foo line 3\n";
1460 let op = Op::Dedent {
1461 find: "foo".to_string(),
1462 amount: 4,
1463 use_tabs: false,
1464 regex: false,
1465 case_insensitive: false,
1466 };
1467 let matcher = Matcher::new(&op).unwrap();
1468 let result = apply(text, &op, &matcher, None, 0).unwrap();
1469 assert_eq!(
1470 result.text.unwrap(),
1471 "foo line 1\n bar line 2\nfoo line 3\n"
1472 );
1473 assert_eq!(result.changes.len(), 2);
1474 }
1475
1476 #[test]
1477 fn test_dedent_no_match() {
1478 let text = " hello world\n";
1479 let op = Op::Dedent {
1480 find: "zzz".to_string(),
1481 amount: 4,
1482 use_tabs: false,
1483 regex: false,
1484 case_insensitive: false,
1485 };
1486 let matcher = Matcher::new(&op).unwrap();
1487 let result = apply(text, &op, &matcher, None, 0).unwrap();
1488 assert!(result.text.is_none());
1489 assert!(result.changes.is_empty());
1490 }
1491
1492 #[test]
1493 fn test_dedent_empty_text() {
1494 let text = "";
1495 let op = Op::Dedent {
1496 find: "anything".to_string(),
1497 amount: 4,
1498 use_tabs: false,
1499 regex: false,
1500 case_insensitive: false,
1501 };
1502 let matcher = Matcher::new(&op).unwrap();
1503 let result = apply(text, &op, &matcher, None, 0).unwrap();
1504 assert!(result.text.is_none());
1505 assert!(result.changes.is_empty());
1506 }
1507
1508 #[test]
1509 fn test_dedent_with_regex() {
1510 let text = " let x = 1;\n fn main() {\n";
1511 let op = Op::Dedent {
1512 find: r"let\s+".to_string(),
1513 amount: 4,
1514 use_tabs: false,
1515 regex: true,
1516 case_insensitive: false,
1517 };
1518 let matcher = Matcher::new(&op).unwrap();
1519 let result = apply(text, &op, &matcher, None, 0).unwrap();
1520 assert_eq!(result.text.unwrap(), "let x = 1;\n fn main() {\n");
1521 assert_eq!(result.changes.len(), 1);
1522 }
1523
1524 #[test]
1525 fn test_dedent_case_insensitive() {
1526 let text = " Hello\n hello\n HELLO\n";
1527 let op = Op::Dedent {
1528 find: "hello".to_string(),
1529 amount: 2,
1530 use_tabs: false,
1531 regex: false,
1532 case_insensitive: true,
1533 };
1534 let matcher = Matcher::new(&op).unwrap();
1535 let result = apply(text, &op, &matcher, None, 0).unwrap();
1536 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
1537 assert_eq!(result.changes.len(), 3);
1538 }
1539
1540 #[test]
1541 fn test_dedent_crlf_preserved() {
1542 let text = " hello\r\nworld\r\n";
1543 let op = Op::Dedent {
1544 find: "hello".to_string(),
1545 amount: 4,
1546 use_tabs: false,
1547 regex: false,
1548 case_insensitive: false,
1549 };
1550 let matcher = Matcher::new(&op).unwrap();
1551 let result = apply(text, &op, &matcher, None, 0).unwrap();
1552 assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
1553 }
1554
1555 #[test]
1556 fn test_dedent_with_line_range() {
1557 let text = " foo\n foo\n foo\n foo\n";
1558 let op = Op::Dedent {
1559 find: "foo".to_string(),
1560 amount: 4,
1561 use_tabs: false,
1562 regex: false,
1563 case_insensitive: false,
1564 };
1565 let range = Some(LineRange {
1566 start: 2,
1567 end: Some(3),
1568 });
1569 let matcher = Matcher::new(&op).unwrap();
1570 let result = apply(text, &op, &matcher, range, 0).unwrap();
1571 assert_eq!(result.text.unwrap(), " foo\nfoo\nfoo\n foo\n");
1572 assert_eq!(result.changes.len(), 2);
1573 }
1574
1575 #[test]
1576 fn test_dedent_only_removes_spaces_not_tabs() {
1577 let text = "\t\thello\n";
1579 let op = Op::Dedent {
1580 find: "hello".to_string(),
1581 amount: 4,
1582 use_tabs: false,
1583 regex: false,
1584 case_insensitive: false,
1585 };
1586 let matcher = Matcher::new(&op).unwrap();
1587 let result = apply(text, &op, &matcher, None, 0).unwrap();
1588 assert!(result.text.is_none());
1591 }
1592
1593 #[test]
1598 fn test_indent_then_dedent_roundtrip() {
1599 let original = "hello world\nfoo bar\n";
1600
1601 let indent_op = Op::Indent {
1603 find: "hello".to_string(),
1604 amount: 4,
1605 use_tabs: false,
1606 regex: false,
1607 case_insensitive: false,
1608 };
1609 let indent_matcher = Matcher::new(&indent_op).unwrap();
1610 let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
1611 let indented_text = indented.text.unwrap();
1612 assert_eq!(indented_text, " hello world\nfoo bar\n");
1613
1614 let dedent_op = Op::Dedent {
1616 find: "hello".to_string(),
1617 amount: 4,
1618 use_tabs: false,
1619 regex: false,
1620 case_insensitive: false,
1621 };
1622 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1623 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1624 assert_eq!(dedented.text.unwrap(), original);
1625 }
1626
1627 #[test]
1632 fn test_transform_undo_stores_original() {
1633 let text = "hello world\n";
1634 let op = Op::Transform {
1635 find: "hello".to_string(),
1636 mode: TransformMode::Upper,
1637 regex: false,
1638 case_insensitive: false,
1639 };
1640 let matcher = Matcher::new(&op).unwrap();
1641 let result = apply(text, &op, &matcher, None, 0).unwrap();
1642 assert_eq!(result.undo.unwrap().original_text, text);
1643 }
1644
1645 #[test]
1646 fn test_surround_undo_stores_original() {
1647 let text = "hello world\n";
1648 let op = Op::Surround {
1649 find: "hello".to_string(),
1650 prefix: "<".to_string(),
1651 suffix: ">".to_string(),
1652 regex: false,
1653 case_insensitive: false,
1654 };
1655 let matcher = Matcher::new(&op).unwrap();
1656 let result = apply(text, &op, &matcher, None, 0).unwrap();
1657 assert_eq!(result.undo.unwrap().original_text, text);
1658 }
1659
1660 #[test]
1661 fn test_indent_undo_stores_original() {
1662 let text = "hello world\n";
1663 let op = Op::Indent {
1664 find: "hello".to_string(),
1665 amount: 4,
1666 use_tabs: false,
1667 regex: false,
1668 case_insensitive: false,
1669 };
1670 let matcher = Matcher::new(&op).unwrap();
1671 let result = apply(text, &op, &matcher, None, 0).unwrap();
1672 assert_eq!(result.undo.unwrap().original_text, text);
1673 }
1674
1675 #[test]
1676 fn test_dedent_undo_stores_original() {
1677 let text = " hello world\n";
1678 let op = Op::Dedent {
1679 find: "hello".to_string(),
1680 amount: 4,
1681 use_tabs: false,
1682 regex: false,
1683 case_insensitive: false,
1684 };
1685 let matcher = Matcher::new(&op).unwrap();
1686 let result = apply(text, &op, &matcher, None, 0).unwrap();
1687 assert_eq!(result.undo.unwrap().original_text, text);
1688 }
1689
1690 #[test]
1695 fn test_transform_preserves_line_count() {
1696 let text = "hello\nworld\nfoo\n";
1697 let op = Op::Transform {
1698 find: "hello".to_string(),
1699 mode: TransformMode::Upper,
1700 regex: false,
1701 case_insensitive: false,
1702 };
1703 let matcher = Matcher::new(&op).unwrap();
1704 let result = apply(text, &op, &matcher, None, 0).unwrap();
1705 let output = result.text.unwrap();
1706 assert_eq!(text.lines().count(), output.lines().count());
1707 }
1708
1709 #[test]
1710 fn test_surround_preserves_line_count() {
1711 let text = "hello\nworld\nfoo\n";
1712 let op = Op::Surround {
1713 find: "hello".to_string(),
1714 prefix: "<".to_string(),
1715 suffix: ">".to_string(),
1716 regex: false,
1717 case_insensitive: false,
1718 };
1719 let matcher = Matcher::new(&op).unwrap();
1720 let result = apply(text, &op, &matcher, None, 0).unwrap();
1721 let output = result.text.unwrap();
1722 assert_eq!(text.lines().count(), output.lines().count());
1723 }
1724
1725 #[test]
1726 fn test_indent_preserves_line_count() {
1727 let text = "hello\nworld\nfoo\n";
1728 let op = Op::Indent {
1729 find: "hello".to_string(),
1730 amount: 4,
1731 use_tabs: false,
1732 regex: false,
1733 case_insensitive: false,
1734 };
1735 let matcher = Matcher::new(&op).unwrap();
1736 let result = apply(text, &op, &matcher, None, 0).unwrap();
1737 let output = result.text.unwrap();
1738 assert_eq!(text.lines().count(), output.lines().count());
1739 }
1740
1741 #[test]
1742 fn test_dedent_preserves_line_count() {
1743 let text = " hello\n world\n foo\n";
1744 let op = Op::Dedent {
1745 find: "hello".to_string(),
1746 amount: 4,
1747 use_tabs: false,
1748 regex: false,
1749 case_insensitive: false,
1750 };
1751 let matcher = Matcher::new(&op).unwrap();
1752 let result = apply(text, &op, &matcher, None, 0).unwrap();
1753 let output = result.text.unwrap();
1754 assert_eq!(text.lines().count(), output.lines().count());
1755 }
1756
1757 #[test]
1766 fn test_line_numbers_with_mixed_line_endings() {
1767 let text = "alpha\nbeta\r\ngamma match\ndelta\r\n";
1772 let op = Op::Replace {
1773 find: "match".to_string(),
1774 replace: "HIT".to_string(),
1775 regex: false,
1776 case_insensitive: false,
1777 };
1778 let matcher = Matcher::new(&op).unwrap();
1779 let result = apply(text, &op, &matcher, None, 0).unwrap();
1780 assert_eq!(result.changes.len(), 1);
1781 assert_eq!(
1782 result.changes[0].line, 3,
1783 "Line number must be 3 regardless of the mixed CR/LF / CRLF endings above it; got {}",
1784 result.changes[0].line
1785 );
1786 assert_eq!(result.changes[0].before, "gamma match");
1787 }
1788
1789 #[test]
1793 fn test_line_number_for_single_line_no_newline() {
1794 let text = "only line matches here";
1795 let op = Op::Replace {
1796 find: "matches".to_string(),
1797 replace: "OK".to_string(),
1798 regex: false,
1799 case_insensitive: false,
1800 };
1801 let matcher = Matcher::new(&op).unwrap();
1802 let result = apply(text, &op, &matcher, None, 0).unwrap();
1803 assert_eq!(result.changes.len(), 1);
1804 assert_eq!(result.changes[0].line, 1);
1805 }
1806
1807 #[test]
1810 fn test_first_line_is_one_not_zero() {
1811 let text = "match first\nother\nother\n";
1812 let op = Op::Replace {
1813 find: "match".to_string(),
1814 replace: "X".to_string(),
1815 regex: false,
1816 case_insensitive: false,
1817 };
1818 let matcher = Matcher::new(&op).unwrap();
1819 let result = apply(text, &op, &matcher, None, 0).unwrap();
1820 assert_eq!(
1821 result.changes[0].line, 1,
1822 "First-line change must be reported as line 1, not 0"
1823 );
1824 }
1825
1826 #[test]
1831 fn test_delete_reports_original_line_number() {
1832 let text = "keep1\ndelete_me\nkeep2\n";
1833 let op = Op::Delete {
1834 find: "delete_me".to_string(),
1835 regex: false,
1836 case_insensitive: false,
1837 };
1838 let matcher = Matcher::new(&op).unwrap();
1839 let result = apply(text, &op, &matcher, None, 0).unwrap();
1840 assert_eq!(result.changes.len(), 1);
1841 assert_eq!(
1842 result.changes[0].line, 2,
1843 "Deleted line's reported line must be its original position (2)"
1844 );
1845 assert_eq!(result.changes[0].before, "delete_me");
1846 assert_eq!(result.changes[0].after, None);
1847 }
1848
1849 #[test]
1856 fn test_delete_all_lines_produces_empty_file() {
1857 let text = "only line\n";
1858 let op = Op::Delete {
1859 find: "only".to_string(),
1860 regex: false,
1861 case_insensitive: false,
1862 };
1863 let matcher = Matcher::new(&op).unwrap();
1864 let result = apply(text, &op, &matcher, None, 0).unwrap();
1865 let output = result.text.unwrap();
1866 assert_eq!(
1867 output, "",
1868 "Deleting every line must yield an empty file, got {output:?}"
1869 );
1870 }
1871
1872 #[test]
1874 fn test_delete_all_lines_crlf_produces_empty_file() {
1875 let text = "only line\r\n";
1876 let op = Op::Delete {
1877 find: "only".to_string(),
1878 regex: false,
1879 case_insensitive: false,
1880 };
1881 let matcher = Matcher::new(&op).unwrap();
1882 let result = apply(text, &op, &matcher, None, 0).unwrap();
1883 let output = result.text.unwrap();
1884 assert_eq!(
1885 output, "",
1886 "Deleting every CRLF line must yield an empty file"
1887 );
1888 }
1889}
1890
1891#[cfg(test)]
1895mod proptests {
1896 use super::*;
1897 use crate::matcher::Matcher;
1898 use crate::operation::Op;
1899 use proptest::prelude::*;
1900
1901 fn arb_multiline_text() -> impl Strategy<Value = String> {
1903 prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
1904 }
1905
1906 fn arb_find_pattern() -> impl Strategy<Value = String> {
1908 "[a-zA-Z0-9]{1,8}"
1909 }
1910
1911 proptest! {
1912 #[test]
1915 fn prop_roundtrip_undo(
1916 text in arb_multiline_text(),
1917 find in arb_find_pattern(),
1918 replace in "[a-zA-Z0-9]{0,8}",
1919 ) {
1920 let op = Op::Replace {
1921 find: find.clone(),
1922 replace: replace.clone(),
1923 regex: false,
1924 case_insensitive: false,
1925 };
1926 let matcher = Matcher::new(&op).unwrap();
1927 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1928
1929 if let Some(undo) = &result.undo {
1930 prop_assert_eq!(&undo.original_text, &text);
1932 }
1933 if result.text.is_none() {
1935 prop_assert!(result.changes.is_empty());
1936 }
1937 }
1938
1939 #[test]
1941 fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
1942 let op = Op::Replace {
1945 find: "\x00\x00NOMATCH\x00\x00".to_string(),
1946 replace: "replacement".to_string(),
1947 regex: false,
1948 case_insensitive: false,
1949 };
1950 let matcher = Matcher::new(&op).unwrap();
1951 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1952 prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
1953 prop_assert!(result.changes.is_empty());
1954 prop_assert!(result.undo.is_none());
1955 }
1956
1957 #[test]
1959 fn prop_deterministic(
1960 text in arb_multiline_text(),
1961 find in arb_find_pattern(),
1962 replace in "[a-zA-Z0-9]{0,8}",
1963 ) {
1964 let op = Op::Replace {
1965 find,
1966 replace,
1967 regex: false,
1968 case_insensitive: false,
1969 };
1970 let matcher = Matcher::new(&op).unwrap();
1971 let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
1972 let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
1973 prop_assert_eq!(&r1.text, &r2.text);
1974 prop_assert_eq!(r1.changes.len(), r2.changes.len());
1975 }
1976
1977 #[test]
1979 fn prop_replace_preserves_line_count(
1980 text in arb_multiline_text(),
1981 find in arb_find_pattern(),
1982 replace in "[a-zA-Z0-9]{0,8}",
1983 ) {
1984 let op = Op::Replace {
1985 find,
1986 replace,
1987 regex: false,
1988 case_insensitive: false,
1989 };
1990 let matcher = Matcher::new(&op).unwrap();
1991 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1992 if let Some(ref output) = result.text {
1993 let input_lines = text.lines().count();
1994 let output_lines = output.lines().count();
1995 prop_assert_eq!(
1996 input_lines,
1997 output_lines,
1998 "Replace should preserve line count: input={} output={}",
1999 input_lines,
2000 output_lines
2001 );
2002 }
2003 }
2004
2005 #[test]
2008 fn prop_indent_dedent_roundtrip(
2009 amount in 1usize..=16,
2010 ) {
2011 let find = "marker".to_string();
2013 let text = "marker line one\nmarker line two\nmarker line three\n";
2014
2015 let indent_op = Op::Indent {
2016 find: find.clone(),
2017 amount,
2018 use_tabs: false,
2019 regex: false,
2020 case_insensitive: false,
2021 };
2022 let indent_matcher = Matcher::new(&indent_op).unwrap();
2023 let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
2024 let indented_text = indented.text.unwrap();
2025
2026 for line in indented_text.lines() {
2028 let leading = line.len() - line.trim_start_matches(' ').len();
2029 prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
2030 }
2031
2032 let dedent_op = Op::Dedent {
2033 find: find.clone(),
2034 amount,
2035 use_tabs: false,
2036 regex: false,
2037 case_insensitive: false,
2038 };
2039 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
2040 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
2041 prop_assert_eq!(dedented.text.unwrap(), text);
2042 }
2043
2044 #[test]
2047 fn prop_transform_upper_lower_roundtrip(
2048 find in "[a-z]{1,8}",
2049 ) {
2050 let text = format!("prefix {find} suffix\n");
2051
2052 let upper_op = Op::Transform {
2053 find: find.clone(),
2054 mode: crate::operation::TransformMode::Upper,
2055 regex: false,
2056 case_insensitive: false,
2057 };
2058 let upper_matcher = Matcher::new(&upper_op).unwrap();
2059 let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
2060
2061 if let Some(ref upper_text) = uppered.text {
2062 let upper_find = find.to_uppercase();
2063 let lower_op = Op::Transform {
2064 find: upper_find,
2065 mode: crate::operation::TransformMode::Lower,
2066 regex: false,
2067 case_insensitive: false,
2068 };
2069 let lower_matcher = Matcher::new(&lower_op).unwrap();
2070 let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
2071 prop_assert_eq!(lowered.text.unwrap(), text);
2072 }
2073 }
2074
2075 #[test]
2077 fn prop_surround_preserves_line_count(
2078 text in arb_multiline_text(),
2079 find in arb_find_pattern(),
2080 ) {
2081 let op = Op::Surround {
2082 find,
2083 prefix: "<<".to_string(),
2084 suffix: ">>".to_string(),
2085 regex: false,
2086 case_insensitive: false,
2087 };
2088 let matcher = Matcher::new(&op).unwrap();
2089 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2090 if let Some(ref output) = result.text {
2091 let input_lines = text.lines().count();
2092 let output_lines = output.lines().count();
2093 prop_assert_eq!(
2094 input_lines,
2095 output_lines,
2096 "Surround should preserve line count: input={} output={}",
2097 input_lines,
2098 output_lines
2099 );
2100 }
2101 }
2102
2103 #[test]
2105 fn prop_transform_preserves_line_count(
2106 text in arb_multiline_text(),
2107 find in arb_find_pattern(),
2108 ) {
2109 let op = Op::Transform {
2110 find,
2111 mode: crate::operation::TransformMode::Upper,
2112 regex: false,
2113 case_insensitive: false,
2114 };
2115 let matcher = Matcher::new(&op).unwrap();
2116 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2117 if let Some(ref output) = result.text {
2118 let input_lines = text.lines().count();
2119 let output_lines = output.lines().count();
2120 prop_assert_eq!(
2121 input_lines,
2122 output_lines,
2123 "Transform should preserve line count: input={} output={}",
2124 input_lines,
2125 output_lines
2126 );
2127 }
2128 }
2129
2130 #[test]
2132 fn prop_indent_preserves_line_count(
2133 text in arb_multiline_text(),
2134 find in arb_find_pattern(),
2135 amount in 1usize..=16,
2136 ) {
2137 let op = Op::Indent {
2138 find,
2139 amount,
2140 use_tabs: false,
2141 regex: false,
2142 case_insensitive: false,
2143 };
2144 let matcher = Matcher::new(&op).unwrap();
2145 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2146 if let Some(ref output) = result.text {
2147 let input_lines = text.lines().count();
2148 let output_lines = output.lines().count();
2149 prop_assert_eq!(
2150 input_lines,
2151 output_lines,
2152 "Indent should preserve line count: input={} output={}",
2153 input_lines,
2154 output_lines
2155 );
2156 }
2157 }
2158
2159 #[test]
2164 fn prop_change_lines_ascending_and_in_bounds(
2165 text in arb_multiline_text(),
2166 find in arb_find_pattern(),
2167 replace in "[a-zA-Z0-9]{0,8}",
2168 ) {
2169 let op = Op::Replace {
2170 find,
2171 replace,
2172 regex: false,
2173 case_insensitive: false,
2174 };
2175 let matcher = Matcher::new(&op).unwrap();
2176 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2177 let max_line = text.lines().count();
2178 let mut prev: usize = 0;
2179 for change in &result.changes {
2180 prop_assert!(change.line >= 1, "Line numbers must be 1-indexed, got {}", change.line);
2181 prop_assert!(
2182 change.line <= max_line,
2183 "Line {} out of input range (max {})",
2184 change.line,
2185 max_line
2186 );
2187 prop_assert!(
2188 change.line > prev,
2189 "Changes must be strictly ascending by line: prev={} current={}",
2190 prev,
2191 change.line
2192 );
2193 prev = change.line;
2194 }
2195 }
2196
2197 #[test]
2201 fn prop_delete_exact_line_count(
2202 find in arb_find_pattern(),
2206 match_count in 0usize..=8,
2207 nonmatch_count in 0usize..=8,
2208 ) {
2209 let match_lines: Vec<String> = (0..match_count)
2210 .map(|_| format!("-- {} --", find))
2211 .collect();
2212 let nonmatch_lines: Vec<String> = (0..nonmatch_count)
2215 .map(|_| "--- filler ---".to_string())
2216 .collect();
2217 for l in &nonmatch_lines {
2219 prop_assume!(!l.contains(&find));
2220 }
2221 let mut merged = Vec::with_capacity(match_count + nonmatch_count);
2223 let mut mi = match_lines.iter();
2224 let mut ni = nonmatch_lines.iter();
2225 loop {
2226 let m = mi.next();
2227 let n = ni.next();
2228 if m.is_none() && n.is_none() { break; }
2229 if let Some(m) = m { merged.push(m.clone()); }
2230 if let Some(n) = n { merged.push(n.clone()); }
2231 }
2232 if merged.is_empty() {
2233 return Ok(());
2235 }
2236 let text = merged.join("\n") + "\n";
2237
2238 let op = Op::Delete {
2239 find: find.clone(),
2240 regex: false,
2241 case_insensitive: false,
2242 };
2243 let matcher = Matcher::new(&op).unwrap();
2244 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2245
2246 let expected_deletions: usize = text.lines().filter(|l| l.contains(&find)).count();
2247 prop_assert_eq!(
2248 expected_deletions,
2249 match_count,
2250 "test construction bug: matcher-count must equal match_count"
2251 );
2252
2253 if expected_deletions == 0 {
2254 prop_assert!(result.text.is_none());
2255 prop_assert!(result.changes.is_empty());
2256 } else {
2257 let input_lines = text.lines().count();
2258 let output_lines = result.text.as_ref().unwrap().lines().count();
2259 prop_assert_eq!(
2260 input_lines - expected_deletions,
2261 output_lines,
2262 "Delete removed wrong number of lines: expected {} - {} = {}, got {}",
2263 input_lines,
2264 expected_deletions,
2265 input_lines - expected_deletions,
2266 output_lines
2267 );
2268 prop_assert_eq!(result.changes.len(), expected_deletions);
2269 }
2270 }
2271
2272 #[test]
2276 fn prop_crlf_majority_preserved(
2277 find in "[a-zA-Z]{1,6}",
2278 replace in "[a-zA-Z]{0,6}",
2279 ) {
2280 let text = format!(
2283 "line {find} one\r\n{find} middle\r\nanother {find} here\r\nending\r\n"
2284 );
2285 let op = Op::Replace {
2286 find: find.clone(),
2287 replace,
2288 regex: false,
2289 case_insensitive: false,
2290 };
2291 let matcher = Matcher::new(&op).unwrap();
2292 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2293 if let Some(ref output) = result.text {
2294 let crlf = output.matches("\r\n").count();
2295 let bare_lf = output.matches('\n').count() - crlf;
2296 prop_assert!(
2297 crlf > bare_lf,
2298 "CRLF majority lost: {crlf} CRLF vs {bare_lf} bare LF in {output:?}"
2299 );
2300 }
2301 }
2302
2303 #[test]
2308 fn prop_replace_change_count_matches_containing_lines(
2309 find in arb_find_pattern(),
2310 replace in "[a-zA-Z]{0,8}",
2311 ) {
2312 let text = format!(
2314 "head\n{find} one\nmiddle\n{find} two {find}\ntail\n"
2315 );
2316 let op = Op::Replace {
2317 find: find.clone(),
2318 replace,
2319 regex: false,
2320 case_insensitive: false,
2321 };
2322 let matcher = Matcher::new(&op).unwrap();
2323 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2324
2325 let expected: usize = text.lines().filter(|l| l.contains(&find)).count();
2326 prop_assert_eq!(result.changes.len(), expected);
2327 }
2328
2329 #[test]
2333 fn prop_no_trailing_newline_preserved(
2334 find in "[a-zA-Z]{1,6}",
2335 replace in "[a-zA-Z]{1,6}",
2336 ) {
2337 let text = format!("first line\nlast line with {find}");
2340 prop_assume!(!text.ends_with('\n'));
2341
2342 let op = Op::Replace {
2343 find,
2344 replace,
2345 regex: false,
2346 case_insensitive: false,
2347 };
2348 let matcher = Matcher::new(&op).unwrap();
2349 let result = apply(&text, &op, &matcher, None, 0).unwrap();
2350 if let Some(ref output) = result.text {
2351 prop_assert!(
2352 !output.ends_with('\n'),
2353 "Spurious trailing newline added to {output:?} (input had none)"
2354 );
2355 }
2356 }
2357 }
2358}