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