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