1use crate::diff::{Change, ChangeContext, FileChanges, OpResult};
2use crate::error::RipsedError;
3use crate::matcher::{MatchSpan, Matcher};
4use crate::operation::{LineRange, Op, RangeSpec, ReplaceCount, TransformMode};
5use crate::undo::UndoEntry;
6
7#[derive(Debug)]
9pub struct EngineOutput {
10 pub text: Option<String>,
12 pub changes: Vec<Change>,
14 pub undo: Option<UndoEntry>,
16}
17
18enum LineAction {
23 Unchanged,
25 Replaced { new_line: String, change: Change },
27 Deleted { change: Change },
29 InsertedAfter { content: String, change: Change },
31 InsertedBefore { content: String, change: Change },
33}
34
35enum RangeFilter {
43 All,
44 Lines(LineRange),
45 Patterns {
46 start: regex::Regex,
47 end: regex::Regex,
48 active: bool,
49 },
50}
51
52impl RangeFilter {
53 fn new(range: Option<&RangeSpec>) -> Result<Self, RipsedError> {
54 match range {
55 None => Ok(RangeFilter::All),
56 Some(RangeSpec::Lines(lines)) => Ok(RangeFilter::Lines(*lines)),
57 Some(RangeSpec::Patterns(patterns)) => {
58 let compile = |which: &str, pattern: &str| {
59 regex::Regex::new(pattern).map_err(|e| {
60 let mut err = RipsedError::invalid_regex(0, pattern, &e.to_string());
61 err.operation_index = None;
62 err.message = format!("Range {which} pattern failed to compile: {e}.");
63 err
64 })
65 };
66 Ok(RangeFilter::Patterns {
67 start: compile("start", &patterns.start_pattern)?,
68 end: compile("end", &patterns.end_pattern)?,
69 active: false,
70 })
71 }
72 }
73 }
74
75 fn admits(&mut self, line_num: usize, line: &str) -> bool {
78 match self {
79 RangeFilter::All => true,
80 RangeFilter::Lines(range) => range.contains(line_num),
81 RangeFilter::Patterns { start, end, active } => {
82 if *active {
83 if end.is_match(line) {
86 *active = false;
87 }
88 true
89 } else if start.is_match(line) {
90 *active = true;
91 true
92 } else {
93 false
94 }
95 }
96 }
97 }
98}
99
100#[cfg(test)]
108fn uses_crlf(text: &str) -> bool {
109 let (crlf, bare_lf) = line_ending_counts(text);
110 crlf > bare_lf
111}
112
113fn line_ending_counts(text: &str) -> (usize, usize) {
117 let bytes = text.as_bytes();
118 let mut crlf = 0usize;
119 let mut bare_lf = 0usize;
120 for pos in memchr::memchr_iter(b'\n', bytes) {
121 if pos > 0 && bytes[pos - 1] == b'\r' {
122 crlf += 1;
123 } else {
124 bare_lf += 1;
125 }
126 }
127 (crlf, bare_lf)
128}
129
130const MAX_CONTEXTED_CHANGES: usize = 1000;
136
137struct LineCtx<'a> {
147 line: &'a str,
148 line_num: usize,
149 matcher: &'a Matcher,
150 lines: &'a [&'a str],
151 idx: usize,
152 context_lines: usize,
153 line_sep: &'a str,
156}
157
158impl LineCtx<'_> {
159 fn maybe_context(&self) -> Option<ChangeContext> {
163 if self.context_lines == 0 {
164 return None;
165 }
166 Some(build_context(self.lines, self.idx, self.context_lines))
167 }
168}
169
170fn apply_replace(
175 cx: &LineCtx,
176 replace: &str,
177 count: ReplaceCount,
178 budget: &mut Option<usize>,
179) -> LineAction {
180 let line_limit = match (count, budget.as_ref()) {
181 (ReplaceCount::FirstPerLine, _) => 1,
182 (_, Some(0)) => return LineAction::Unchanged, (_, Some(remaining)) => *remaining,
184 (_, None) => 0, };
186 if let Some((replaced, occurrences)) = cx.matcher.replace_n(cx.line, replace, line_limit) {
187 if let Some(remaining) = budget {
188 *remaining = remaining.saturating_sub(occurrences);
189 }
190 LineAction::Replaced {
191 new_line: replaced.clone(),
192 change: Change {
193 line: cx.line_num,
194 before: cx.line.to_string(),
195 after: Some(replaced),
196 context: cx.maybe_context(),
197 },
198 }
199 } else {
200 LineAction::Unchanged
201 }
202}
203
204fn replace_budget(op: &Op) -> Option<usize> {
207 match op {
208 Op::Replace { count, .. } => match count {
209 ReplaceCount::All | ReplaceCount::FirstPerLine => None,
210 ReplaceCount::FirstInFile => Some(1),
211 ReplaceCount::Max(n) => Some(*n),
212 },
213 _ => None,
214 }
215}
216
217fn apply_delete(cx: &LineCtx) -> LineAction {
219 if cx.matcher.is_match(cx.line) {
220 LineAction::Deleted {
221 change: Change {
222 line: cx.line_num,
223 before: cx.line.to_string(),
224 after: None,
225 context: cx.maybe_context(),
226 },
227 }
228 } else {
229 LineAction::Unchanged
230 }
231}
232
233fn apply_insert_after(cx: &LineCtx, content: &str) -> LineAction {
235 if cx.matcher.is_match(cx.line) {
236 LineAction::InsertedAfter {
237 content: content.to_string(),
238 change: Change {
239 line: cx.line_num,
240 before: cx.line.to_string(),
241 after: Some(format!("{}{}{content}", cx.line, cx.line_sep)),
242 context: cx.maybe_context(),
243 },
244 }
245 } else {
246 LineAction::Unchanged
247 }
248}
249
250fn apply_insert_before(cx: &LineCtx, content: &str) -> LineAction {
252 if cx.matcher.is_match(cx.line) {
253 LineAction::InsertedBefore {
254 content: content.to_string(),
255 change: Change {
256 line: cx.line_num,
257 before: cx.line.to_string(),
258 after: Some(format!("{content}{}{}", cx.line_sep, cx.line)),
259 context: cx.maybe_context(),
260 },
261 }
262 } else {
263 LineAction::Unchanged
264 }
265}
266
267fn apply_replace_line(cx: &LineCtx, content: &str) -> LineAction {
269 if cx.matcher.is_match(cx.line) {
270 LineAction::Replaced {
271 new_line: content.to_string(),
272 change: Change {
273 line: cx.line_num,
274 before: cx.line.to_string(),
275 after: Some(content.to_string()),
276 context: cx.maybe_context(),
277 },
278 }
279 } else {
280 LineAction::Unchanged
281 }
282}
283
284fn apply_transform_op(cx: &LineCtx, mode: TransformMode) -> LineAction {
286 if cx.matcher.is_match(cx.line) {
287 let new_line = apply_transform(cx.line, cx.matcher, mode);
288 if new_line != cx.line {
289 LineAction::Replaced {
290 new_line: new_line.clone(),
291 change: Change {
292 line: cx.line_num,
293 before: cx.line.to_string(),
294 after: Some(new_line),
295 context: cx.maybe_context(),
296 },
297 }
298 } else {
299 LineAction::Unchanged
300 }
301 } else {
302 LineAction::Unchanged
303 }
304}
305
306fn apply_surround(cx: &LineCtx, prefix: &str, suffix: &str) -> LineAction {
308 if cx.matcher.is_match(cx.line) {
309 let new_line = format!("{prefix}{}{suffix}", cx.line);
310 if new_line != cx.line {
311 LineAction::Replaced {
312 new_line: new_line.clone(),
313 change: Change {
314 line: cx.line_num,
315 before: cx.line.to_string(),
316 after: Some(new_line),
317 context: cx.maybe_context(),
318 },
319 }
320 } else {
321 LineAction::Unchanged
322 }
323 } else {
324 LineAction::Unchanged
325 }
326}
327
328fn apply_indent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
330 if cx.matcher.is_match(cx.line) {
331 let indent = if use_tabs {
332 "\t".repeat(amount)
333 } else {
334 " ".repeat(amount)
335 };
336 let new_line = format!("{indent}{}", cx.line);
337 if new_line != cx.line {
338 LineAction::Replaced {
339 new_line: new_line.clone(),
340 change: Change {
341 line: cx.line_num,
342 before: cx.line.to_string(),
343 after: Some(new_line),
344 context: cx.maybe_context(),
345 },
346 }
347 } else {
348 LineAction::Unchanged
349 }
350 } else {
351 LineAction::Unchanged
352 }
353}
354
355fn apply_dedent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
357 if cx.matcher.is_match(cx.line) {
358 let new_line = dedent_line(cx.line, amount, use_tabs);
359 if new_line != cx.line {
360 LineAction::Replaced {
361 new_line: new_line.clone(),
362 change: Change {
363 line: cx.line_num,
364 before: cx.line.to_string(),
365 after: Some(new_line),
366 context: cx.maybe_context(),
367 },
368 }
369 } else {
370 LineAction::Unchanged
371 }
372 } else {
373 LineAction::Unchanged
374 }
375}
376
377pub fn apply(
382 text: &str,
383 op: &Op,
384 matcher: &Matcher,
385 range: Option<RangeSpec>,
386 context_lines: usize,
387) -> Result<EngineOutput, RipsedError> {
388 if op.is_multiline() {
389 if range.is_some() {
390 return Err(RipsedError::invalid_request(
391 "ranges are not supported in multiline mode",
392 "multiline patterns match against the whole buffer; remove the range or the multiline flag",
393 ));
394 }
395 if matches!(
396 op,
397 Op::Replace {
398 count: ReplaceCount::FirstPerLine,
399 ..
400 }
401 ) {
402 return Err(RipsedError::invalid_request(
403 "first_per_line count is not supported in multiline mode",
404 "per-line counting has no meaning when matching the whole buffer; use first_in_file or {\"max\": n} instead",
405 ));
406 }
407 return Ok(apply_multiline(text, op, matcher, context_lines));
408 }
409
410 let mut range_filter = RangeFilter::new(range.as_ref())?;
411
412 if !matcher.prescreen(text) {
418 return Ok(EngineOutput {
419 text: None,
420 changes: Vec::new(),
421 undo: None,
422 });
423 }
424
425 let (crlf_count, bare_lf_count) = line_ending_counts(text);
426 let crlf = crlf_count > bare_lf_count;
427 let line_sep = if crlf { "\r\n" } else { "\n" };
428
429 if splice_eligible(op, &range, crlf_count, bare_lf_count) {
432 return Ok(apply_spliced(text, op, matcher, context_lines));
433 }
434
435 let lines: Vec<&str> = text.lines().collect();
436 let mut result_lines: Vec<String> = Vec::with_capacity(lines.len());
437 let mut changes: Vec<Change> = Vec::new();
438 let mut budget = replace_budget(op);
439
440 for (idx, &line) in lines.iter().enumerate() {
441 let line_num = idx + 1; if !range_filter.admits(line_num, line) {
445 result_lines.push(line.to_string());
446 continue;
447 }
448
449 let cx = LineCtx {
450 line,
451 line_num,
452 matcher,
453 lines: &lines,
454 idx,
455 context_lines: if changes.len() >= MAX_CONTEXTED_CHANGES {
457 0
458 } else {
459 context_lines
460 },
461 line_sep,
462 };
463
464 let action = dispatch_op(op, &cx, &mut budget);
465
466 match action {
467 LineAction::Unchanged => {
468 result_lines.push(line.to_string());
469 }
470 LineAction::Replaced { new_line, change } => {
471 changes.push(change);
472 result_lines.push(new_line);
473 }
474 LineAction::Deleted { change } => {
475 changes.push(change);
476 }
478 LineAction::InsertedAfter { content, change } => {
479 result_lines.push(line.to_string());
480 changes.push(change);
481 result_lines.push(content);
482 }
483 LineAction::InsertedBefore { content, change } => {
484 changes.push(change);
485 result_lines.push(content);
486 result_lines.push(line.to_string());
487 }
488 }
489 }
490
491 let modified_text = if changes.is_empty() {
492 None
493 } else {
494 let mut joined = result_lines.join(line_sep);
498 if !result_lines.is_empty() && (text.ends_with('\n') || text.ends_with("\r\n")) {
499 joined.push_str(line_sep);
500 }
501 Some(joined)
502 };
503
504 let undo = if !changes.is_empty() {
505 Some(UndoEntry {
506 original_text: text.to_string(),
507 })
508 } else {
509 None
510 };
511
512 Ok(EngineOutput {
513 text: modified_text,
514 changes,
515 undo,
516 })
517}
518
519fn splice_eligible(op: &Op, range: &Option<RangeSpec>, crlf: usize, bare_lf: usize) -> bool {
537 if range.is_some() || (crlf > 0 && bare_lf > 0) {
538 return false;
539 }
540 match op {
541 Op::Replace {
542 find, regex, count, ..
543 } => {
544 !find.is_empty()
551 && !*regex
552 && *count != ReplaceCount::FirstPerLine
553 && !find.contains('\n')
554 && !find.contains('\r')
555 }
556 _ => false,
557 }
558}
559
560fn apply_spliced(text: &str, op: &Op, matcher: &Matcher, context_lines: usize) -> EngineOutput {
565 let replace = match op {
566 Op::Replace { replace, .. } => replace.as_str(),
567 _ => unreachable!("splice path only handles Replace"),
569 };
570
571 let mut spans = matcher.find_replacements(text, replace);
572 if let Some(limit) = replace_budget(op) {
573 spans.truncate(limit);
574 }
575 if spans.is_empty() {
576 return EngineOutput {
577 text: None,
578 changes: Vec::new(),
579 undo: None,
580 };
581 }
582
583 let bytes = text.as_bytes();
584 let mut out = String::with_capacity(text.len());
585 let mut changes: Vec<Change> = Vec::new();
586 let mut last_end = 0usize; let mut line_num = 1usize; let mut scan = 0usize; let mut i = 0;
591 while i < spans.len() {
592 let group_first = spans[i].start;
593 line_num += memchr::memchr_iter(b'\n', &bytes[scan..group_first]).count();
595 scan = group_first;
596
597 let line_begin = memchr::memrchr(b'\n', &bytes[..group_first]).map_or(0, |p| p + 1);
600 let line_end =
601 memchr::memchr(b'\n', &bytes[group_first..]).map_or(text.len(), |p| group_first + p);
602 let content_end = if line_end > line_begin && bytes[line_end - 1] == b'\r' {
603 line_end - 1
604 } else {
605 line_end
606 };
607
608 debug_assert!(
615 spans[i].start < line_end,
616 "splice span must fall inside its line"
617 );
618 if spans[i].start >= line_end {
619 i += 1;
620 continue;
621 }
622 let before_line = &text[line_begin..content_end];
623 let mut after_line = String::with_capacity(before_line.len());
624 let mut line_cursor = line_begin;
625 while i < spans.len() && spans[i].start < line_end {
626 let span = &spans[i];
627 after_line.push_str(&text[line_cursor..span.start]);
628 after_line.push_str(&span.replacement);
629 out.push_str(&text[last_end..span.start]);
630 out.push_str(&span.replacement);
631 line_cursor = span.end;
632 last_end = span.end;
633 i += 1;
634 }
635 after_line.push_str(&text[line_cursor..content_end]);
636
637 let context = if context_lines == 0 || changes.len() >= MAX_CONTEXTED_CHANGES {
638 None
639 } else {
640 Some(splice_line_context(
641 text,
642 line_begin,
643 line_end,
644 context_lines,
645 ))
646 };
647 changes.push(Change {
648 line: line_num,
649 before: before_line.to_string(),
650 after: Some(after_line),
651 context,
652 });
653 }
654 out.push_str(&text[last_end..]);
655
656 EngineOutput {
657 text: Some(out),
658 changes,
659 undo: Some(UndoEntry {
660 original_text: text.to_string(),
661 }),
662 }
663}
664
665fn splice_line_context(
669 text: &str,
670 line_begin: usize,
671 line_end: usize,
672 context_lines: usize,
673) -> ChangeContext {
674 let bytes = text.as_bytes();
675 let strip_cr = |s: &str| s.strip_suffix('\r').unwrap_or(s).to_string();
676
677 let mut before = Vec::new();
678 let mut end = line_begin; for _ in 0..context_lines {
680 if end == 0 {
681 break;
682 }
683 let term = end - 1; let begin = memchr::memrchr(b'\n', &bytes[..term]).map_or(0, |p| p + 1);
687 let content = &text[begin..term];
688 before.push(strip_cr(content));
689 end = begin;
690 }
691 before.reverse();
692
693 let mut after = Vec::new();
694 let mut begin = match line_end {
695 e if e >= text.len() => text.len(),
696 e => e + 1, };
698 for _ in 0..context_lines {
699 if begin >= text.len() {
700 break;
701 }
702 let term = memchr::memchr(b'\n', &bytes[begin..]).map_or(text.len(), |p| begin + p);
703 after.push(strip_cr(&text[begin..term]));
704 begin = if term >= text.len() {
705 text.len()
706 } else {
707 term + 1
708 };
709 }
710
711 ChangeContext { before, after }
712}
713
714fn dispatch_op(op: &Op, cx: &LineCtx, budget: &mut Option<usize>) -> LineAction {
719 match op {
720 Op::Replace { replace, count, .. } => apply_replace(cx, replace, *count, budget),
721 Op::Delete { .. } => apply_delete(cx),
722 Op::InsertAfter { content, .. } => apply_insert_after(cx, content),
723 Op::InsertBefore { content, .. } => apply_insert_before(cx, content),
724 Op::ReplaceLine { content, .. } => apply_replace_line(cx, content),
725 Op::Transform { mode, .. } => apply_transform_op(cx, *mode),
726 Op::Surround { prefix, suffix, .. } => apply_surround(cx, prefix, suffix),
727 Op::Indent {
728 amount, use_tabs, ..
729 } => apply_indent(cx, *amount, *use_tabs),
730 Op::Dedent {
731 amount, use_tabs, ..
732 } => apply_dedent(cx, *amount, *use_tabs),
733 }
734}
735
736#[derive(Debug, PartialEq, Eq)]
738pub struct StreamedLine {
739 pub lines: Vec<String>,
742 pub changed: bool,
744}
745
746pub struct LineProcessor<'a> {
757 op: &'a Op,
758 matcher: &'a Matcher,
759 range_filter: RangeFilter,
760 budget: Option<usize>,
761 line_num: usize,
762}
763
764impl<'a> LineProcessor<'a> {
765 pub fn new(
766 op: &'a Op,
767 matcher: &'a Matcher,
768 range: Option<RangeSpec>,
769 ) -> Result<Self, RipsedError> {
770 if op.is_multiline() {
771 return Err(RipsedError::invalid_request(
772 "multiline operations cannot be streamed",
773 "multiline patterns match against the whole buffer; buffer the input instead",
774 ));
775 }
776 Ok(Self {
777 op,
778 matcher,
779 range_filter: RangeFilter::new(range.as_ref())?,
780 budget: replace_budget(op),
781 line_num: 0,
782 })
783 }
784
785 pub fn process_line(&mut self, line: &str) -> StreamedLine {
787 self.line_num += 1;
788 if !self.range_filter.admits(self.line_num, line) {
789 return StreamedLine {
790 lines: vec![line.to_string()],
791 changed: false,
792 };
793 }
794 let lines_slice = [line];
797 let cx = LineCtx {
798 line,
799 line_num: self.line_num,
800 matcher: self.matcher,
801 lines: &lines_slice,
802 idx: 0,
803 context_lines: 0,
804 line_sep: "\n",
805 };
806 match dispatch_op(self.op, &cx, &mut self.budget) {
807 LineAction::Unchanged => StreamedLine {
808 lines: vec![line.to_string()],
809 changed: false,
810 },
811 LineAction::Replaced { new_line, .. } => StreamedLine {
812 lines: vec![new_line],
813 changed: true,
814 },
815 LineAction::Deleted { .. } => StreamedLine {
816 lines: Vec::new(),
817 changed: true,
818 },
819 LineAction::InsertedAfter { content, .. } => StreamedLine {
820 lines: vec![line.to_string(), content],
821 changed: true,
822 },
823 LineAction::InsertedBefore { content, .. } => StreamedLine {
824 lines: vec![content, line.to_string()],
825 changed: true,
826 },
827 }
828 }
829}
830
831fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
833 match matcher {
834 Matcher::Literal { pattern, .. } => {
835 line.replace(pattern.as_str(), &transform_text(pattern, mode))
836 }
837 Matcher::Regex { re, .. } => {
838 let result = re.replace_all(line, |caps: ®ex::Captures| {
839 transform_text(&caps[0], mode)
840 });
841 result.into_owned()
842 }
843 }
844}
845
846fn transform_text(text: &str, mode: TransformMode) -> String {
848 match mode {
849 TransformMode::Upper => text.to_uppercase(),
850 TransformMode::Lower => text.to_lowercase(),
851 TransformMode::Title => {
852 let mut result = String::with_capacity(text.len());
853 let mut capitalize_next = true;
854 for ch in text.chars() {
855 if ch.is_whitespace() || ch == '_' || ch == '-' {
856 result.push(ch);
857 capitalize_next = true;
858 } else if capitalize_next {
859 for upper in ch.to_uppercase() {
860 result.push(upper);
861 }
862 capitalize_next = false;
863 } else {
864 result.push(ch);
865 }
866 }
867 result
868 }
869 TransformMode::SnakeCase => {
870 let mut result = String::with_capacity(text.len() + 4);
871 let mut prev_was_lower = false;
872 for ch in text.chars() {
873 if ch.is_uppercase() {
874 if prev_was_lower {
875 result.push('_');
876 }
877 for lower in ch.to_lowercase() {
878 result.push(lower);
879 }
880 prev_was_lower = false;
881 } else if ch == '-' || ch == ' ' {
882 result.push('_');
883 prev_was_lower = false;
884 } else {
885 result.push(ch);
886 prev_was_lower = ch.is_lowercase();
887 }
888 }
889 result
890 }
891 TransformMode::CamelCase => {
892 let mut result = String::with_capacity(text.len());
893 let mut capitalize_next = false;
894 let mut first = true;
895 for ch in text.chars() {
896 if ch == '_' || ch == '-' || ch == ' ' {
897 capitalize_next = true;
898 } else if capitalize_next {
899 for upper in ch.to_uppercase() {
900 result.push(upper);
901 }
902 capitalize_next = false;
903 } else if first {
904 for lower in ch.to_lowercase() {
905 result.push(lower);
906 }
907 first = false;
908 } else {
909 result.push(ch);
910 first = false;
911 }
912 }
913 result
914 }
915 }
916}
917
918fn dedent_line(line: &str, amount: usize, use_tabs: bool) -> String {
921 let ch = if use_tabs { '\t' } else { ' ' };
922 let leading = line.len() - line.trim_start_matches(ch).len();
923 let remove = leading.min(amount);
924 line[remove..].to_string()
925}
926
927fn apply_multiline(text: &str, op: &Op, matcher: &Matcher, context_lines: usize) -> EngineOutput {
939 let (replacement, is_delete) = match op {
942 Op::Replace { replace, .. } => (replace.as_str(), false),
943 _ => ("", true),
944 };
945
946 let mut spans = matcher.find_replacements(text, replacement);
947 if let Some(limit) = replace_budget(op) {
950 spans.truncate(limit);
951 }
952 if spans.is_empty() {
953 return EngineOutput {
954 text: None,
955 changes: Vec::new(),
956 undo: None,
957 };
958 }
959
960 let lines: Vec<&str> = text.lines().collect();
961 let mut out = String::with_capacity(text.len());
962 let mut changes = Vec::with_capacity(spans.len());
963 let mut last_end = 0usize;
964
965 for MatchSpan {
966 start,
967 end,
968 replacement,
969 } in spans
970 {
971 out.push_str(&text[last_end..start]);
972 let before = &text[start..end];
973 let start_line_idx = text[..start].matches('\n').count();
974 let end_line_idx = start_line_idx + before.matches('\n').count();
975 changes.push(Change {
976 line: start_line_idx + 1,
977 before: before.to_string(),
978 after: if is_delete {
979 None
980 } else {
981 Some(replacement.clone())
982 },
983 context: if context_lines == 0 {
984 None
985 } else {
986 Some(build_span_context(
987 &lines,
988 start_line_idx,
989 end_line_idx,
990 context_lines,
991 ))
992 },
993 });
994 out.push_str(&replacement);
995 last_end = end;
996 }
997 out.push_str(&text[last_end..]);
998
999 EngineOutput {
1000 text: Some(out),
1001 changes,
1002 undo: Some(UndoEntry {
1003 original_text: text.to_string(),
1004 }),
1005 }
1006}
1007
1008fn build_span_context(
1012 lines: &[&str],
1013 start_idx: usize,
1014 end_idx: usize,
1015 context_lines: usize,
1016) -> ChangeContext {
1017 let before_start = start_idx.saturating_sub(context_lines);
1018 let before = lines[before_start..start_idx.min(lines.len())]
1019 .iter()
1020 .map(|s| s.to_string())
1021 .collect();
1022 let after_start = (end_idx + 1).min(lines.len());
1023 let after_end = (end_idx + 1 + context_lines).min(lines.len());
1024 let after = lines[after_start..after_end]
1025 .iter()
1026 .map(|s| s.to_string())
1027 .collect();
1028 ChangeContext { before, after }
1029}
1030
1031fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
1032 let start = idx.saturating_sub(context_lines);
1033 let end = (idx + context_lines + 1).min(lines.len());
1034
1035 let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
1036 let after = if idx + 1 < end {
1037 lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
1038 } else {
1039 vec![]
1040 };
1041
1042 ChangeContext { before, after }
1043}
1044
1045pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
1047 OpResult {
1048 operation_index,
1049 files: if changes.is_empty() {
1050 vec![]
1051 } else {
1052 vec![FileChanges {
1053 path: path.to_string(),
1054 changes,
1055 }]
1056 },
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063 use crate::matcher::Matcher;
1064
1065 #[test]
1066 fn test_simple_replace() {
1067 let text = "hello world\nfoo bar\nhello again\n";
1068 let op = Op::Replace {
1069 count: Default::default(),
1070 multiline: false,
1071 find: "hello".to_string(),
1072 replace: "hi".to_string(),
1073 regex: false,
1074 case_insensitive: false,
1075 };
1076 let matcher = Matcher::new(&op).unwrap();
1077 let result = apply(text, &op, &matcher, None, 2).unwrap();
1078 assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
1079 assert_eq!(result.changes.len(), 2);
1080 }
1081
1082 #[test]
1083 fn test_delete_lines() {
1084 let text = "keep\ndelete me\nkeep too\n";
1085 let op = Op::Delete {
1086 multiline: false,
1087 find: "delete".to_string(),
1088 regex: false,
1089 case_insensitive: false,
1090 };
1091 let matcher = Matcher::new(&op).unwrap();
1092 let result = apply(text, &op, &matcher, None, 0).unwrap();
1093 assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
1094 }
1095
1096 #[test]
1097 fn test_no_changes() {
1098 let text = "nothing matches here\n";
1099 let op = Op::Replace {
1100 count: Default::default(),
1101 multiline: false,
1102 find: "zzz".to_string(),
1103 replace: "aaa".to_string(),
1104 regex: false,
1105 case_insensitive: false,
1106 };
1107 let matcher = Matcher::new(&op).unwrap();
1108 let result = apply(text, &op, &matcher, None, 0).unwrap();
1109 assert!(result.text.is_none());
1110 assert!(result.changes.is_empty());
1111 }
1112
1113 #[test]
1114 fn test_line_range() {
1115 let text = "line1\nline2\nline3\nline4\n";
1116 let op = Op::Replace {
1117 count: Default::default(),
1118 multiline: false,
1119 find: "line".to_string(),
1120 replace: "row".to_string(),
1121 regex: false,
1122 case_insensitive: false,
1123 };
1124 let range = Some(LineRange {
1125 start: 2,
1126 end: Some(3),
1127 });
1128 let matcher = Matcher::new(&op).unwrap();
1129 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
1130 assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
1131 }
1132
1133 #[test]
1138 fn test_crlf_replace_preserves_crlf() {
1139 let text = "hello world\r\nfoo bar\r\nhello again\r\n";
1140 let op = Op::Replace {
1141 count: Default::default(),
1142 multiline: false,
1143 find: "hello".to_string(),
1144 replace: "hi".to_string(),
1145 regex: false,
1146 case_insensitive: false,
1147 };
1148 let matcher = Matcher::new(&op).unwrap();
1149 let result = apply(text, &op, &matcher, None, 0).unwrap();
1150 assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
1151 }
1152
1153 #[test]
1154 fn test_crlf_delete_preserves_crlf() {
1155 let text = "keep\r\ndelete me\r\nkeep too\r\n";
1156 let op = Op::Delete {
1157 multiline: false,
1158 find: "delete".to_string(),
1159 regex: false,
1160 case_insensitive: false,
1161 };
1162 let matcher = Matcher::new(&op).unwrap();
1163 let result = apply(text, &op, &matcher, None, 0).unwrap();
1164 assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
1165 }
1166
1167 #[test]
1168 fn test_crlf_no_trailing_newline() {
1169 let text = "hello world\r\nfoo bar";
1170 let op = Op::Replace {
1171 count: Default::default(),
1172 multiline: false,
1173 find: "hello".to_string(),
1174 replace: "hi".to_string(),
1175 regex: false,
1176 case_insensitive: false,
1177 };
1178 let matcher = Matcher::new(&op).unwrap();
1179 let result = apply(text, &op, &matcher, None, 0).unwrap();
1180 let output = result.text.unwrap();
1181 assert_eq!(output, "hi world\r\nfoo bar");
1182 assert!(!output.ends_with("\r\n"));
1184 }
1185
1186 #[test]
1187 fn test_crlf_insert_after_metadata_uses_crlf() {
1188 let text = "alpha\r\nbeta\r\n";
1189 let op = Op::InsertAfter {
1190 find: "alpha".to_string(),
1191 content: "inserted".to_string(),
1192 regex: false,
1193 case_insensitive: false,
1194 };
1195 let matcher = Matcher::new(&op).unwrap();
1196 let result = apply(text, &op, &matcher, None, 0).unwrap();
1197
1198 let output = result.text.unwrap();
1199 assert_eq!(output, "alpha\r\ninserted\r\nbeta\r\n");
1200
1201 let after = result.changes[0].after.as_deref().unwrap();
1204 assert_eq!(after, "alpha\r\ninserted");
1205 assert!(
1206 output.contains(after),
1207 "metadata {after:?} must appear verbatim in output {output:?}"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_crlf_insert_before_metadata_uses_crlf() {
1213 let text = "alpha\r\nbeta\r\n";
1214 let op = Op::InsertBefore {
1215 find: "beta".to_string(),
1216 content: "inserted".to_string(),
1217 regex: false,
1218 case_insensitive: false,
1219 };
1220 let matcher = Matcher::new(&op).unwrap();
1221 let result = apply(text, &op, &matcher, None, 0).unwrap();
1222
1223 let output = result.text.unwrap();
1224 assert_eq!(output, "alpha\r\ninserted\r\nbeta\r\n");
1225
1226 let after = result.changes[0].after.as_deref().unwrap();
1227 assert_eq!(after, "inserted\r\nbeta");
1228 assert!(
1229 output.contains(after),
1230 "metadata {after:?} must appear verbatim in output {output:?}"
1231 );
1232 }
1233
1234 #[test]
1235 fn test_lf_insert_after_metadata_uses_lf() {
1236 let text = "alpha\nbeta\n";
1238 let op = Op::InsertAfter {
1239 find: "alpha".to_string(),
1240 content: "inserted".to_string(),
1241 regex: false,
1242 case_insensitive: false,
1243 };
1244 let matcher = Matcher::new(&op).unwrap();
1245 let result = apply(text, &op, &matcher, None, 0).unwrap();
1246 let after = result.changes[0].after.as_deref().unwrap();
1247 assert_eq!(after, "alpha\ninserted");
1248 }
1249
1250 fn multiline_replace_op(find: &str, replace: &str, regex: bool) -> Op {
1253 Op::Replace {
1254 count: Default::default(),
1255 find: find.to_string(),
1256 replace: replace.to_string(),
1257 regex,
1258 case_insensitive: false,
1259 multiline: true,
1260 }
1261 }
1262
1263 fn multiline_delete_op(find: &str, regex: bool) -> Op {
1264 Op::Delete {
1265 find: find.to_string(),
1266 regex,
1267 case_insensitive: false,
1268 multiline: true,
1269 }
1270 }
1271
1272 #[test]
1273 fn test_multiline_literal_replace_across_lines() {
1274 let text = "fn old(\n x: u32,\n) {}\n";
1275 let op = multiline_replace_op("old(\n x: u32,\n)", "new(x: u32)", false);
1276 let matcher = Matcher::new(&op).unwrap();
1277 let result = apply(text, &op, &matcher, None, 0).unwrap();
1278 assert_eq!(result.text.unwrap(), "fn new(x: u32) {}\n");
1279 assert_eq!(result.changes.len(), 1);
1280 assert_eq!(result.changes[0].line, 1);
1281 assert_eq!(result.changes[0].before, "old(\n x: u32,\n)");
1282 assert_eq!(result.changes[0].after.as_deref(), Some("new(x: u32)"));
1283 }
1284
1285 #[test]
1286 fn test_multiline_regex_captures_across_lines() {
1287 let text = "alpha\nbeta\ngamma\n";
1288 let op = multiline_replace_op(r"(\w+)\n(\w+)\n", "$2\n$1\n", true);
1290 let matcher = Matcher::new(&op).unwrap();
1291 let result = apply(text, &op, &matcher, None, 0).unwrap();
1292 assert_eq!(result.text.unwrap(), "beta\nalpha\ngamma\n");
1293 assert_eq!(result.changes[0].after.as_deref(), Some("beta\nalpha\n"));
1294 }
1295
1296 #[test]
1297 fn test_multiline_delete_removes_span_not_lines() {
1298 let text = "keep [START]\ndoomed\n[END] keep\n";
1299 let op = multiline_delete_op("[START]\ndoomed\n[END]", false);
1300 let matcher = Matcher::new(&op).unwrap();
1301 let result = apply(text, &op, &matcher, None, 0).unwrap();
1302 assert_eq!(result.text.unwrap(), "keep keep\n");
1304 assert_eq!(result.changes[0].after, None);
1305 assert_eq!(result.changes[0].before, "[START]\ndoomed\n[END]");
1306 }
1307
1308 #[test]
1309 fn test_multiline_crlf_metadata_matches_output_bytes() {
1310 let text = "alpha\r\nbeta\r\ngamma\r\n";
1311 let op = multiline_replace_op("alpha\r\nbeta", "one\r\ntwo", false);
1312 let matcher = Matcher::new(&op).unwrap();
1313 let result = apply(text, &op, &matcher, None, 0).unwrap();
1314 let output = result.text.unwrap();
1315 assert_eq!(output, "one\r\ntwo\r\ngamma\r\n");
1316 let change = &result.changes[0];
1317 assert_eq!(change.before, "alpha\r\nbeta");
1318 let after = change.after.as_deref().unwrap();
1319 assert_eq!(after, "one\r\ntwo");
1320 assert!(
1321 output.contains(after),
1322 "metadata {after:?} must appear verbatim in output {output:?}"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_multiline_match_at_eof_without_trailing_newline() {
1328 let text = "head\ntail";
1329 let op = multiline_replace_op("head\ntail", "joined", false);
1330 let matcher = Matcher::new(&op).unwrap();
1331 let result = apply(text, &op, &matcher, None, 0).unwrap();
1332 let output = result.text.unwrap();
1333 assert_eq!(output, "joined");
1334 assert!(!output.ends_with('\n'));
1335 }
1336
1337 #[test]
1338 fn test_multiline_preserves_untouched_separators() {
1339 let text = "a\r\nMARK\nb\n";
1342 let op = multiline_replace_op("MARK", "X", false);
1343 let matcher = Matcher::new(&op).unwrap();
1344 let result = apply(text, &op, &matcher, None, 0).unwrap();
1345 assert_eq!(result.text.unwrap(), "a\r\nX\nb\n");
1346 }
1347
1348 #[test]
1349 fn test_multiline_with_line_range_is_rejected() {
1350 let op = multiline_replace_op("a", "b", false);
1351 let matcher = Matcher::new(&op).unwrap();
1352 let range = LineRange {
1353 start: 1,
1354 end: Some(2),
1355 };
1356 let err = apply("a\n", &op, &matcher, Some(RangeSpec::Lines(range)), 0).unwrap_err();
1357 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1358 }
1359
1360 #[test]
1361 fn test_multiline_change_line_numbers_ascending_and_correct() {
1362 let text = "x\nfoo\nx\nfoo\nx\nfoo\n";
1363 let op = multiline_replace_op("foo\nx", "bar\nx", false);
1364 let matcher = Matcher::new(&op).unwrap();
1365 let result = apply(text, &op, &matcher, None, 0).unwrap();
1366 let line_numbers: Vec<usize> = result.changes.iter().map(|c| c.line).collect();
1368 assert_eq!(line_numbers, vec![2, 4]);
1369 assert_eq!(result.text.unwrap(), "x\nbar\nx\nbar\nx\nfoo\n");
1370 }
1371
1372 #[test]
1373 fn test_multiline_no_match_returns_none() {
1374 let op = multiline_replace_op("absent\npattern", "x", false);
1375 let matcher = Matcher::new(&op).unwrap();
1376 let result = apply("some\ntext\n", &op, &matcher, None, 0).unwrap();
1377 assert!(result.text.is_none());
1378 assert!(result.changes.is_empty());
1379 assert!(result.undo.is_none());
1380 }
1381
1382 #[test]
1383 fn test_multiline_delete_everything_yields_empty_string() {
1384 let text = "all\ngone\n";
1385 let op = multiline_delete_op("all\ngone\n", false);
1386 let matcher = Matcher::new(&op).unwrap();
1387 let result = apply(text, &op, &matcher, None, 0).unwrap();
1388 assert_eq!(result.text.unwrap(), "");
1389 }
1390
1391 #[test]
1392 fn test_multiline_undo_roundtrip() {
1393 let text = "one\ntwo\nthree\n";
1394 let op = multiline_replace_op("one\ntwo", "1\n2", false);
1395 let matcher = Matcher::new(&op).unwrap();
1396 let result = apply(text, &op, &matcher, None, 0).unwrap();
1397 assert_eq!(result.text.unwrap(), "1\n2\nthree\n");
1398 assert_eq!(result.undo.unwrap().original_text, text);
1399 }
1400
1401 #[test]
1402 fn test_multiline_output_equals_matcher_replace() {
1403 let text = "aaa\nbbb aaa\nccc\n";
1406 let op = multiline_replace_op("aa", "Z", false);
1407 let matcher = Matcher::new(&op).unwrap();
1408 let result = apply(text, &op, &matcher, None, 0).unwrap();
1409 assert_eq!(
1410 result.text.unwrap(),
1411 matcher.replace(text, "Z").unwrap(),
1412 "span splicing must match replace_all output"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_multiline_span_context() {
1418 let text = "ctx1\nctx2\nA\nB\nctx3\nctx4\n";
1419 let op = multiline_replace_op("A\nB", "AB", false);
1420 let matcher = Matcher::new(&op).unwrap();
1421 let result = apply(text, &op, &matcher, None, 1).unwrap();
1422 let ctx = result.changes[0].context.as_ref().unwrap();
1423 assert_eq!(ctx.before, vec!["ctx2".to_string()]);
1424 assert_eq!(ctx.after, vec!["ctx3".to_string()]);
1425 }
1426
1427 fn counted_replace_op(find: &str, replace: &str, count: ReplaceCount) -> Op {
1430 Op::Replace {
1431 find: find.to_string(),
1432 replace: replace.to_string(),
1433 regex: false,
1434 case_insensitive: false,
1435 multiline: false,
1436 count,
1437 }
1438 }
1439
1440 #[test]
1441 fn test_count_first_per_line_replaces_one_per_line() {
1442 let text = "a a a\nx\na a\n";
1443 let op = counted_replace_op("a", "B", ReplaceCount::FirstPerLine);
1444 let matcher = Matcher::new(&op).unwrap();
1445 let result = apply(text, &op, &matcher, None, 0).unwrap();
1446 assert_eq!(result.text.unwrap(), "B a a\nx\nB a\n");
1447 assert_eq!(result.changes.len(), 2);
1448 }
1449
1450 #[test]
1451 fn test_count_first_in_file_replaces_only_first_occurrence() {
1452 let text = "a a\na\na\n";
1453 let op = counted_replace_op("a", "B", ReplaceCount::FirstInFile);
1454 let matcher = Matcher::new(&op).unwrap();
1455 let result = apply(text, &op, &matcher, None, 0).unwrap();
1456 assert_eq!(result.text.unwrap(), "B a\na\na\n");
1457 assert_eq!(result.changes.len(), 1);
1458 assert_eq!(result.changes[0].line, 1);
1459 }
1460
1461 #[test]
1462 fn test_count_max_spans_lines_and_counts_occurrences() {
1463 let text = "a a\na a\na\n";
1466 let op = counted_replace_op("a", "B", ReplaceCount::Max(3));
1467 let matcher = Matcher::new(&op).unwrap();
1468 let result = apply(text, &op, &matcher, None, 0).unwrap();
1469 assert_eq!(result.text.unwrap(), "B B\nB a\na\n");
1470 assert_eq!(result.changes.len(), 2);
1471 }
1472
1473 #[test]
1474 fn test_count_all_is_default_behavior() {
1475 let text = "a a\na\n";
1476 let op = counted_replace_op("a", "B", ReplaceCount::All);
1477 let matcher = Matcher::new(&op).unwrap();
1478 let result = apply(text, &op, &matcher, None, 0).unwrap();
1479 assert_eq!(result.text.unwrap(), "B B\nB\n");
1480 }
1481
1482 #[test]
1483 fn test_count_first_per_line_with_regex_captures() {
1484 let text = "x1 x2 x3\n";
1485 let op = Op::Replace {
1486 find: r"x(\d)".to_string(),
1487 replace: "y$1".to_string(),
1488 regex: true,
1489 case_insensitive: false,
1490 multiline: false,
1491 count: ReplaceCount::FirstPerLine,
1492 };
1493 let matcher = Matcher::new(&op).unwrap();
1494 let result = apply(text, &op, &matcher, None, 0).unwrap();
1495 assert_eq!(result.text.unwrap(), "y1 x2 x3\n");
1496 }
1497
1498 #[test]
1499 fn test_count_max_in_multiline_mode_truncates_spans() {
1500 let text = "a\na\na\n";
1501 let op = Op::Replace {
1502 find: "a\n".to_string(),
1503 replace: "B\n".to_string(),
1504 regex: false,
1505 case_insensitive: false,
1506 multiline: true,
1507 count: ReplaceCount::Max(2),
1508 };
1509 let matcher = Matcher::new(&op).unwrap();
1510 let result = apply(text, &op, &matcher, None, 0).unwrap();
1511 assert_eq!(result.text.unwrap(), "B\nB\na\n");
1512 assert_eq!(result.changes.len(), 2);
1513 }
1514
1515 #[test]
1516 fn test_count_first_in_file_in_multiline_mode() {
1517 let text = "a\na\n";
1518 let op = Op::Replace {
1519 find: "a".to_string(),
1520 replace: "B".to_string(),
1521 regex: false,
1522 case_insensitive: false,
1523 multiline: true,
1524 count: ReplaceCount::FirstInFile,
1525 };
1526 let matcher = Matcher::new(&op).unwrap();
1527 let result = apply(text, &op, &matcher, None, 0).unwrap();
1528 assert_eq!(result.text.unwrap(), "B\na\n");
1529 }
1530
1531 #[test]
1532 fn test_count_first_per_line_rejected_in_multiline_mode() {
1533 let op = Op::Replace {
1534 find: "a".to_string(),
1535 replace: "B".to_string(),
1536 regex: false,
1537 case_insensitive: false,
1538 multiline: true,
1539 count: ReplaceCount::FirstPerLine,
1540 };
1541 let matcher = Matcher::new(&op).unwrap();
1542 let err = apply("a\n", &op, &matcher, None, 0).unwrap_err();
1543 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1544 }
1545
1546 #[test]
1547 fn test_count_budget_exhausted_skips_remaining_lines() {
1548 let text = "a\na\na\na\n";
1551 let op = counted_replace_op("a", "B", ReplaceCount::Max(1));
1552 let matcher = Matcher::new(&op).unwrap();
1553 let result = apply(text, &op, &matcher, None, 0).unwrap();
1554 assert_eq!(result.text.unwrap(), "B\na\na\na\n");
1555 assert_eq!(result.changes.len(), 1);
1556 }
1557
1558 fn pattern_range(start: &str, end: &str) -> Option<RangeSpec> {
1561 Some(RangeSpec::Patterns(crate::operation::PatternRange {
1562 start_pattern: start.to_string(),
1563 end_pattern: end.to_string(),
1564 }))
1565 }
1566
1567 fn simple_replace(find: &str, replace: &str) -> Op {
1568 Op::Replace {
1569 find: find.to_string(),
1570 replace: replace.to_string(),
1571 regex: false,
1572 case_insensitive: false,
1573 multiline: false,
1574 count: ReplaceCount::All,
1575 }
1576 }
1577
1578 #[test]
1579 fn test_pattern_range_single_region_boundaries_inclusive() {
1580 let text = "x\nBEGIN x\nx\nEND x\nx\n";
1581 let op = simple_replace("x", "y");
1582 let matcher = Matcher::new(&op).unwrap();
1583 let result = apply(text, &op, &matcher, pattern_range("BEGIN", "END"), 0).unwrap();
1584 assert_eq!(result.text.unwrap(), "x\nBEGIN y\ny\nEND y\nx\n");
1586 }
1587
1588 #[test]
1589 fn test_pattern_range_multiple_regions() {
1590 let text = "x\nA\nx\nB\nx\nA\nx\nB\nx\n";
1591 let op = simple_replace("x", "y");
1592 let matcher = Matcher::new(&op).unwrap();
1593 let result = apply(text, &op, &matcher, pattern_range("A", "B"), 0).unwrap();
1594 assert_eq!(result.text.unwrap(), "x\nA\ny\nB\nx\nA\ny\nB\nx\n");
1597 }
1598
1599 #[test]
1600 fn test_pattern_range_unterminated_extends_to_eof() {
1601 let text = "x\nBEGIN\nx\nx\n";
1602 let op = simple_replace("x", "y");
1603 let matcher = Matcher::new(&op).unwrap();
1604 let result = apply(text, &op, &matcher, pattern_range("BEGIN", "NEVER"), 0).unwrap();
1605 assert_eq!(result.text.unwrap(), "x\nBEGIN\ny\ny\n");
1606 }
1607
1608 #[test]
1609 fn test_pattern_range_same_start_and_end_spans_to_next_match() {
1610 let text = "MARK\nx\nMARK\nx\n";
1613 let op = simple_replace("x", "y");
1614 let matcher = Matcher::new(&op).unwrap();
1615 let result = apply(text, &op, &matcher, pattern_range("MARK", "MARK"), 0).unwrap();
1616 assert_eq!(result.text.unwrap(), "MARK\ny\nMARK\nx\n");
1617 }
1618
1619 #[test]
1620 fn test_pattern_range_delete_can_remove_boundary_lines() {
1621 let text = "keep\nSTART\ngone\nSTOP\nkeep\n";
1622 let op = Op::Delete {
1623 find: ".*".to_string(),
1624 regex: true,
1625 case_insensitive: false,
1626 multiline: false,
1627 };
1628 let matcher = Matcher::new(&op).unwrap();
1629 let result = apply(text, &op, &matcher, pattern_range("START", "STOP"), 0).unwrap();
1630 assert_eq!(result.text.unwrap(), "keep\nkeep\n");
1631 }
1632
1633 #[test]
1634 fn test_pattern_range_start_regex_anchors() {
1635 let text = "prefix BEGIN\nx\nBEGIN\nx\nEND\n";
1636 let op = simple_replace("x", "y");
1637 let matcher = Matcher::new(&op).unwrap();
1638 let result = apply(text, &op, &matcher, pattern_range("^BEGIN$", "END"), 0).unwrap();
1640 assert_eq!(result.text.unwrap(), "prefix BEGIN\nx\nBEGIN\ny\nEND\n");
1641 }
1642
1643 #[test]
1644 fn test_pattern_range_invalid_regex_is_rejected() {
1645 let op = simple_replace("x", "y");
1646 let matcher = Matcher::new(&op).unwrap();
1647 let err = apply("x\n", &op, &matcher, pattern_range("(unclosed", "END"), 0).unwrap_err();
1648 assert_eq!(err.code, crate::error::ErrorCode::InvalidRegex);
1649 assert!(err.message.contains("start"));
1650 }
1651
1652 #[test]
1653 fn test_pattern_range_rejected_in_multiline_mode() {
1654 let op = Op::Replace {
1655 find: "x".to_string(),
1656 replace: "y".to_string(),
1657 regex: false,
1658 case_insensitive: false,
1659 multiline: true,
1660 count: ReplaceCount::All,
1661 };
1662 let matcher = Matcher::new(&op).unwrap();
1663 let err = apply("x\n", &op, &matcher, pattern_range("A", "B"), 0).unwrap_err();
1664 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1665 }
1666
1667 #[test]
1668 fn test_pattern_range_no_region_means_no_changes() {
1669 let op = simple_replace("x", "y");
1670 let matcher = Matcher::new(&op).unwrap();
1671 let result = apply("x\nx\n", &op, &matcher, pattern_range("NEVER", "END"), 0).unwrap();
1672 assert!(result.text.is_none());
1673 assert!(result.changes.is_empty());
1674 }
1675
1676 #[test]
1679 fn test_line_processor_matches_buffered_apply() {
1680 let text = "alpha x\nplain\nx x\n";
1683 let op = simple_replace("x", "y");
1684 let matcher = Matcher::new(&op).unwrap();
1685
1686 let buffered = apply(text, &op, &matcher, None, 0).unwrap().text.unwrap();
1687
1688 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1689 let mut streamed = String::new();
1690 for line in text.lines() {
1691 for out in processor.process_line(line).lines {
1692 streamed.push_str(&out);
1693 streamed.push('\n');
1694 }
1695 }
1696 assert_eq!(streamed, buffered);
1697 }
1698
1699 #[test]
1700 fn test_line_processor_rejects_multiline_ops() {
1701 let op = Op::Replace {
1702 find: "a\nb".to_string(),
1703 replace: "ab".to_string(),
1704 regex: false,
1705 case_insensitive: false,
1706 multiline: true,
1707 count: ReplaceCount::All,
1708 };
1709 let matcher = Matcher::new(&op).unwrap();
1710 let err = match LineProcessor::new(&op, &matcher, None) {
1711 Err(e) => e,
1712 Ok(_) => panic!("multiline op must be rejected by LineProcessor"),
1713 };
1714 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1715 }
1716
1717 #[test]
1718 fn test_line_processor_budget_spans_calls() {
1719 let op = counted_replace_op("x", "y", ReplaceCount::Max(2));
1720 let matcher = Matcher::new(&op).unwrap();
1721 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1722 assert!(processor.process_line("x x").changed); let third = processor.process_line("x");
1724 assert!(!third.changed, "budget exhausted across calls");
1725 assert_eq!(third.lines, vec!["x".to_string()]);
1726 }
1727
1728 #[test]
1729 fn test_line_processor_pattern_range_state_spans_calls() {
1730 let op = simple_replace("x", "y");
1731 let matcher = Matcher::new(&op).unwrap();
1732 let range = pattern_range("BEGIN", "END");
1733 let mut processor = LineProcessor::new(&op, &matcher, range).unwrap();
1734 assert_eq!(processor.process_line("x").lines, vec!["x"]); processor.process_line("BEGIN");
1736 assert_eq!(processor.process_line("x").lines, vec!["y"]); processor.process_line("END");
1738 assert_eq!(processor.process_line("x").lines, vec!["x"]); }
1740
1741 #[test]
1742 fn test_line_processor_insert_and_delete_shapes() {
1743 let op = Op::InsertAfter {
1744 find: "mark".to_string(),
1745 content: "inserted".to_string(),
1746 regex: false,
1747 case_insensitive: false,
1748 };
1749 let matcher = Matcher::new(&op).unwrap();
1750 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1751 assert_eq!(
1752 processor.process_line("mark").lines,
1753 vec!["mark".to_string(), "inserted".to_string()]
1754 );
1755
1756 let op = Op::Delete {
1757 find: "gone".to_string(),
1758 regex: false,
1759 case_insensitive: false,
1760 multiline: false,
1761 };
1762 let matcher = Matcher::new(&op).unwrap();
1763 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1764 let out = processor.process_line("gone");
1765 assert!(out.lines.is_empty());
1766 assert!(out.changed);
1767 }
1768
1769 fn line_path_range() -> Option<RangeSpec> {
1775 Some(RangeSpec::Lines(LineRange {
1776 start: 1,
1777 end: None,
1778 }))
1779 }
1780
1781 #[test]
1782 fn test_splice_matches_line_path_exactly() {
1783 for (text, find, replace) in [
1784 ("a x b\nx\nno match\nx x x\n", "x", "YY"),
1785 ("x at start\nend x", "x", ""),
1786 ("a\r\nx here\r\nx x\r\n", "x", "longer-replacement"),
1787 ("only\none\nmatch deep in file x\n", "x", "y"),
1788 ("x", "x", "swap"),
1789 ("multi x on x one x line\n", "x", "[]"),
1790 ] {
1791 let op = simple_replace(find, replace);
1792 let matcher = Matcher::new(&op).unwrap();
1793 let spliced = apply(text, &op, &matcher, None, 2).unwrap();
1794 let looped = apply(text, &op, &matcher, line_path_range(), 2).unwrap();
1795 assert_eq!(spliced.text, looped.text, "text for {text:?}");
1796 assert_eq!(spliced.changes, looped.changes, "changes for {text:?}");
1797 }
1798 }
1799
1800 #[test]
1801 fn test_splice_case_insensitive_literal() {
1802 let text = "Foo bar\nFOO\nplain\n";
1803 let op = Op::Replace {
1804 find: "foo".to_string(),
1805 replace: "baz".to_string(),
1806 regex: false,
1807 case_insensitive: true,
1808 multiline: false,
1809 count: ReplaceCount::All,
1810 };
1811 let matcher = Matcher::new(&op).unwrap();
1812 let spliced = apply(text, &op, &matcher, None, 1).unwrap();
1813 let looped = apply(text, &op, &matcher, line_path_range(), 1).unwrap();
1814 assert_eq!(spliced.text, looped.text);
1815 assert_eq!(spliced.changes, looped.changes);
1816 assert_eq!(spliced.text.unwrap(), "baz bar\nbaz\nplain\n");
1817 }
1818
1819 #[test]
1820 fn test_splice_count_parity() {
1821 let text = "x x\nx x\nx\n";
1822 for count in [ReplaceCount::FirstInFile, ReplaceCount::Max(3)] {
1823 let op = counted_replace_op("x", "y", count);
1824 let matcher = Matcher::new(&op).unwrap();
1825 let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1826 let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1827 assert_eq!(spliced.text, looped.text, "{count:?}");
1828 assert_eq!(spliced.changes, looped.changes, "{count:?}");
1829 }
1830 }
1831
1832 #[test]
1833 fn test_empty_find_takes_line_path_and_terminates() {
1834 let text = "a\r\nb";
1839 let op = simple_replace("", "");
1840 let matcher = Matcher::new(&op).unwrap();
1841 let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1842 let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1843 assert_eq!(spliced.text, looped.text);
1844 assert_eq!(spliced.changes, looped.changes);
1845
1846 let op = simple_replace("", "X");
1848 let matcher = Matcher::new(&op).unwrap();
1849 let result = apply("ab\n", &op, &matcher, None, 0).unwrap();
1850 assert_eq!(result.text.unwrap(), "XaXbX\n");
1851 }
1852
1853 #[test]
1854 fn test_splice_mixed_endings_falls_back_and_normalizes() {
1855 let text = "x\r\nx\r\nx\n";
1858 let op = simple_replace("x", "y");
1859 let matcher = Matcher::new(&op).unwrap();
1860 let result = apply(text, &op, &matcher, None, 0).unwrap();
1861 assert_eq!(result.text.unwrap(), "y\r\ny\r\ny\r\n");
1862 }
1863
1864 #[test]
1865 fn test_splice_replacement_with_newline() {
1866 let text = "a SPLIT b\nplain\n";
1867 let op = simple_replace("SPLIT", "one\ntwo");
1868 let matcher = Matcher::new(&op).unwrap();
1869 let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1870 let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1871 assert_eq!(spliced.text, looped.text);
1872 assert_eq!(spliced.text.unwrap(), "a one\ntwo b\nplain\n");
1873 }
1874
1875 #[test]
1876 fn test_context_capped_after_threshold_on_both_paths() {
1877 let text = "x\n".repeat(1100);
1880 let op = simple_replace("x", "y");
1881 let matcher = Matcher::new(&op).unwrap();
1882 for range in [None, line_path_range()] {
1883 let result = apply(&text, &op, &matcher, range, 1).unwrap();
1884 assert_eq!(result.changes.len(), 1100);
1885 assert!(result.changes[0].context.is_some());
1886 assert!(result.changes[999].context.is_some());
1887 assert!(result.changes[1000].context.is_none());
1888 assert!(result.changes[1099].context.is_none());
1889 }
1890 }
1891
1892 #[test]
1893 fn test_uses_crlf_detection() {
1894 assert!(uses_crlf("a\r\nb\r\n"));
1895 assert!(uses_crlf("a\r\n"));
1896 assert!(!uses_crlf("a\nb\n"));
1897 assert!(!uses_crlf("no newline at all"));
1898 assert!(!uses_crlf(""));
1899 }
1900
1901 #[test]
1906 fn test_empty_input_text() {
1907 let text = "";
1908 let op = Op::Replace {
1909 count: Default::default(),
1910 multiline: false,
1911 find: "anything".to_string(),
1912 replace: "something".to_string(),
1913 regex: false,
1914 case_insensitive: false,
1915 };
1916 let matcher = Matcher::new(&op).unwrap();
1917 let result = apply(text, &op, &matcher, None, 0).unwrap();
1918 assert!(result.text.is_none());
1919 assert!(result.changes.is_empty());
1920 }
1921
1922 #[test]
1923 fn test_single_line_no_trailing_newline() {
1924 let text = "hello world";
1925 let op = Op::Replace {
1926 count: Default::default(),
1927 multiline: false,
1928 find: "hello".to_string(),
1929 replace: "hi".to_string(),
1930 regex: false,
1931 case_insensitive: false,
1932 };
1933 let matcher = Matcher::new(&op).unwrap();
1934 let result = apply(text, &op, &matcher, None, 0).unwrap();
1935 let output = result.text.unwrap();
1936 assert_eq!(output, "hi world");
1937 assert!(!output.ends_with('\n'));
1939 }
1940
1941 #[test]
1942 fn test_whitespace_only_lines() {
1943 let text = " \n\t\n \t \n";
1944 let op = Op::Replace {
1945 count: Default::default(),
1946 multiline: false,
1947 find: "\t".to_string(),
1948 replace: "TAB".to_string(),
1949 regex: false,
1950 case_insensitive: false,
1951 };
1952 let matcher = Matcher::new(&op).unwrap();
1953 let result = apply(text, &op, &matcher, None, 0).unwrap();
1954 let output = result.text.unwrap();
1955 assert!(output.contains("TAB"));
1956 assert_eq!(result.changes.len(), 2); }
1958
1959 #[test]
1960 fn test_very_long_line() {
1961 let long_word = "x".repeat(100_000);
1962 let text = format!("before\n{long_word}\nafter\n");
1963 let op = Op::Replace {
1964 count: Default::default(),
1965 multiline: false,
1966 find: "x".to_string(),
1967 replace: "y".to_string(),
1968 regex: false,
1969 case_insensitive: false,
1970 };
1971 let matcher = Matcher::new(&op).unwrap();
1972 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1973 let output = result.text.unwrap();
1974 let expected_long = "y".repeat(100_000);
1975 assert!(output.contains(&expected_long));
1976 }
1977
1978 #[test]
1979 fn test_unicode_emoji() {
1980 let text = "hello world\n";
1981 let op = Op::Replace {
1982 count: Default::default(),
1983 multiline: false,
1984 find: "world".to_string(),
1985 replace: "\u{1F30D}".to_string(), regex: false,
1987 case_insensitive: false,
1988 };
1989 let matcher = Matcher::new(&op).unwrap();
1990 let result = apply(text, &op, &matcher, None, 0).unwrap();
1991 assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
1992 }
1993
1994 #[test]
1995 fn test_unicode_cjk() {
1996 let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; let op = Op::Replace {
1998 count: Default::default(),
1999 multiline: false,
2000 find: "\u{4E16}\u{754C}".to_string(), replace: "\u{5730}\u{7403}".to_string(), regex: false,
2003 case_insensitive: false,
2004 };
2005 let matcher = Matcher::new(&op).unwrap();
2006 let result = apply(text, &op, &matcher, None, 0).unwrap();
2007 assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
2008 }
2009
2010 #[test]
2011 fn test_unicode_combining_characters() {
2012 let text = "caf\u{0065}\u{0301}\n";
2014 let op = Op::Replace {
2015 count: Default::default(),
2016 multiline: false,
2017 find: "caf\u{0065}\u{0301}".to_string(),
2018 replace: "coffee".to_string(),
2019 regex: false,
2020 case_insensitive: false,
2021 };
2022 let matcher = Matcher::new(&op).unwrap();
2023 let result = apply(text, &op, &matcher, None, 0).unwrap();
2024 assert_eq!(result.text.unwrap(), "coffee\n");
2025 }
2026
2027 #[test]
2028 fn test_regex_special_chars_in_literal_mode() {
2029 let text = "price is $10.00 (USD)\n";
2031 let op = Op::Replace {
2032 count: Default::default(),
2033 multiline: false,
2034 find: "$10.00".to_string(),
2035 replace: "$20.00".to_string(),
2036 regex: false,
2037 case_insensitive: false,
2038 };
2039 let matcher = Matcher::new(&op).unwrap();
2040 let result = apply(text, &op, &matcher, None, 0).unwrap();
2041 assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
2042 }
2043
2044 #[test]
2045 fn test_overlapping_matches_in_single_line() {
2046 let text = "aaa\n";
2048 let op = Op::Replace {
2049 count: Default::default(),
2050 multiline: false,
2051 find: "aa".to_string(),
2052 replace: "b".to_string(),
2053 regex: false,
2054 case_insensitive: false,
2055 };
2056 let matcher = Matcher::new(&op).unwrap();
2057 let result = apply(text, &op, &matcher, None, 0).unwrap();
2058 assert_eq!(result.text.unwrap(), "ba\n");
2060 }
2061
2062 #[test]
2063 fn test_replace_line_count_preserved() {
2064 let text = "line1\nline2\nline3\nline4\nline5\n";
2065 let input_line_count = text.lines().count();
2066 let op = Op::Replace {
2067 count: Default::default(),
2068 multiline: false,
2069 find: "line".to_string(),
2070 replace: "row".to_string(),
2071 regex: false,
2072 case_insensitive: false,
2073 };
2074 let matcher = Matcher::new(&op).unwrap();
2075 let result = apply(text, &op, &matcher, None, 0).unwrap();
2076 let output = result.text.unwrap();
2077 let output_line_count = output.lines().count();
2078 assert_eq!(input_line_count, output_line_count);
2079 }
2080
2081 #[test]
2082 fn test_replace_preserves_empty_result_on_non_match() {
2083 let text = "alpha\nbeta\ngamma\n";
2085 let op = Op::Replace {
2086 count: Default::default(),
2087 multiline: false,
2088 find: "zzzzzz".to_string(),
2089 replace: "y".to_string(),
2090 regex: false,
2091 case_insensitive: false,
2092 };
2093 let matcher = Matcher::new(&op).unwrap();
2094 let result = apply(text, &op, &matcher, None, 0).unwrap();
2095 assert!(result.text.is_none());
2096 assert!(result.undo.is_none());
2097 }
2098
2099 #[test]
2100 fn test_undo_entry_stores_original() {
2101 let text = "hello\nworld\n";
2102 let op = Op::Replace {
2103 count: Default::default(),
2104 multiline: false,
2105 find: "hello".to_string(),
2106 replace: "hi".to_string(),
2107 regex: false,
2108 case_insensitive: false,
2109 };
2110 let matcher = Matcher::new(&op).unwrap();
2111 let result = apply(text, &op, &matcher, None, 0).unwrap();
2112 let undo = result.undo.unwrap();
2113 assert_eq!(undo.original_text, text);
2114 }
2115
2116 #[test]
2117 fn test_determinism_same_input_same_output() {
2118 let text = "foo bar baz\nhello world\nfoo again\n";
2119 let op = Op::Replace {
2120 count: Default::default(),
2121 multiline: false,
2122 find: "foo".to_string(),
2123 replace: "qux".to_string(),
2124 regex: false,
2125 case_insensitive: false,
2126 };
2127 let matcher = Matcher::new(&op).unwrap();
2128 let r1 = apply(text, &op, &matcher, None, 0).unwrap();
2129 let r2 = apply(text, &op, &matcher, None, 0).unwrap();
2130 assert_eq!(r1.text, r2.text);
2131 assert_eq!(r1.changes.len(), r2.changes.len());
2132 for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
2133 assert_eq!(c1, c2);
2134 }
2135 }
2136
2137 #[test]
2142 fn test_transform_upper() {
2143 let text = "hello world\nfoo bar\n";
2144 let op = Op::Transform {
2145 find: "hello".to_string(),
2146 mode: TransformMode::Upper,
2147 regex: false,
2148 case_insensitive: false,
2149 };
2150 let matcher = Matcher::new(&op).unwrap();
2151 let result = apply(text, &op, &matcher, None, 0).unwrap();
2152 assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
2153 assert_eq!(result.changes.len(), 1);
2154 assert_eq!(result.changes[0].line, 1);
2155 }
2156
2157 #[test]
2158 fn test_transform_lower() {
2159 let text = "HELLO WORLD\nFOO BAR\n";
2160 let op = Op::Transform {
2161 find: "HELLO".to_string(),
2162 mode: TransformMode::Lower,
2163 regex: false,
2164 case_insensitive: false,
2165 };
2166 let matcher = Matcher::new(&op).unwrap();
2167 let result = apply(text, &op, &matcher, None, 0).unwrap();
2168 assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
2169 assert_eq!(result.changes.len(), 1);
2170 }
2171
2172 #[test]
2173 fn test_transform_noop_when_already_target_case() {
2174 let text = "hello world\nfoo bar\n";
2176 let op = Op::Transform {
2177 find: "hello".to_string(),
2178 mode: TransformMode::Lower,
2179 regex: false,
2180 case_insensitive: false,
2181 };
2182 let matcher = Matcher::new(&op).unwrap();
2183 let result = apply(text, &op, &matcher, None, 0).unwrap();
2184 assert!(result.text.is_none(), "No text modification expected");
2185 assert!(result.changes.is_empty(), "No changes expected");
2186 }
2187
2188 #[test]
2189 fn test_transform_title() {
2190 let text = "hello world\nfoo bar\n";
2191 let op = Op::Transform {
2192 find: "hello world".to_string(),
2193 mode: TransformMode::Title,
2194 regex: false,
2195 case_insensitive: false,
2196 };
2197 let matcher = Matcher::new(&op).unwrap();
2198 let result = apply(text, &op, &matcher, None, 0).unwrap();
2199 assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
2200 assert_eq!(result.changes.len(), 1);
2201 }
2202
2203 #[test]
2204 fn test_transform_snake_case() {
2205 let text = "let myVariable = 1;\nother line\n";
2206 let op = Op::Transform {
2207 find: "myVariable".to_string(),
2208 mode: TransformMode::SnakeCase,
2209 regex: false,
2210 case_insensitive: false,
2211 };
2212 let matcher = Matcher::new(&op).unwrap();
2213 let result = apply(text, &op, &matcher, None, 0).unwrap();
2214 assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
2215 assert_eq!(result.changes.len(), 1);
2216 }
2217
2218 #[test]
2219 fn test_transform_camel_case() {
2220 let text = "let my_variable = 1;\nother line\n";
2221 let op = Op::Transform {
2222 find: "my_variable".to_string(),
2223 mode: TransformMode::CamelCase,
2224 regex: false,
2225 case_insensitive: false,
2226 };
2227 let matcher = Matcher::new(&op).unwrap();
2228 let result = apply(text, &op, &matcher, None, 0).unwrap();
2229 assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
2230 assert_eq!(result.changes.len(), 1);
2231 }
2232
2233 #[test]
2234 fn test_transform_upper_multiple_matches_on_line() {
2235 let text = "hello and hello again\n";
2236 let op = Op::Transform {
2237 find: "hello".to_string(),
2238 mode: TransformMode::Upper,
2239 regex: false,
2240 case_insensitive: false,
2241 };
2242 let matcher = Matcher::new(&op).unwrap();
2243 let result = apply(text, &op, &matcher, None, 0).unwrap();
2244 assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
2245 }
2246
2247 #[test]
2248 fn test_transform_no_match() {
2249 let text = "hello world\n";
2250 let op = Op::Transform {
2251 find: "zzz".to_string(),
2252 mode: TransformMode::Upper,
2253 regex: false,
2254 case_insensitive: false,
2255 };
2256 let matcher = Matcher::new(&op).unwrap();
2257 let result = apply(text, &op, &matcher, None, 0).unwrap();
2258 assert!(result.text.is_none());
2259 assert!(result.changes.is_empty());
2260 }
2261
2262 #[test]
2263 fn test_transform_empty_text() {
2264 let text = "";
2265 let op = Op::Transform {
2266 find: "anything".to_string(),
2267 mode: TransformMode::Upper,
2268 regex: false,
2269 case_insensitive: false,
2270 };
2271 let matcher = Matcher::new(&op).unwrap();
2272 let result = apply(text, &op, &matcher, None, 0).unwrap();
2273 assert!(result.text.is_none());
2274 assert!(result.changes.is_empty());
2275 }
2276
2277 #[test]
2278 fn test_transform_with_regex() {
2279 let text = "let fooBar = 1;\nlet bazQux = 2;\n";
2280 let op = Op::Transform {
2281 find: r"[a-z]+[A-Z]\w*".to_string(),
2282 mode: TransformMode::SnakeCase,
2283 regex: true,
2284 case_insensitive: false,
2285 };
2286 let matcher = Matcher::new(&op).unwrap();
2287 let result = apply(text, &op, &matcher, None, 0).unwrap();
2288 let output = result.text.unwrap();
2289 assert!(output.contains("foo_bar"));
2290 assert!(output.contains("baz_qux"));
2291 assert_eq!(result.changes.len(), 2);
2292 }
2293
2294 #[test]
2295 fn test_transform_case_insensitive() {
2296 let text = "Hello HELLO hello\n";
2297 let op = Op::Transform {
2298 find: "hello".to_string(),
2299 mode: TransformMode::Upper,
2300 regex: false,
2301 case_insensitive: true,
2302 };
2303 let matcher = Matcher::new(&op).unwrap();
2304 let result = apply(text, &op, &matcher, None, 0).unwrap();
2305 assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
2306 }
2307
2308 #[test]
2309 fn test_transform_crlf_preserved() {
2310 let text = "hello world\r\nfoo bar\r\n";
2311 let op = Op::Transform {
2312 find: "hello".to_string(),
2313 mode: TransformMode::Upper,
2314 regex: false,
2315 case_insensitive: false,
2316 };
2317 let matcher = Matcher::new(&op).unwrap();
2318 let result = apply(text, &op, &matcher, None, 0).unwrap();
2319 assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
2320 }
2321
2322 #[test]
2323 fn test_transform_with_line_range() {
2324 let text = "hello\nhello\nhello\nhello\n";
2325 let op = Op::Transform {
2326 find: "hello".to_string(),
2327 mode: TransformMode::Upper,
2328 regex: false,
2329 case_insensitive: false,
2330 };
2331 let range = Some(LineRange {
2332 start: 2,
2333 end: Some(3),
2334 });
2335 let matcher = Matcher::new(&op).unwrap();
2336 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2337 assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
2338 assert_eq!(result.changes.len(), 2);
2339 }
2340
2341 #[test]
2342 fn test_transform_title_with_underscores() {
2343 let text = "my_func_name\n";
2344 let op = Op::Transform {
2345 find: "my_func_name".to_string(),
2346 mode: TransformMode::Title,
2347 regex: false,
2348 case_insensitive: false,
2349 };
2350 let matcher = Matcher::new(&op).unwrap();
2351 let result = apply(text, &op, &matcher, None, 0).unwrap();
2352 assert_eq!(result.text.unwrap(), "My_Func_Name\n");
2354 }
2355
2356 #[test]
2357 fn test_transform_snake_case_from_multi_word() {
2358 let text = "my-kebab-case\n";
2359 let op = Op::Transform {
2360 find: "my-kebab-case".to_string(),
2361 mode: TransformMode::SnakeCase,
2362 regex: false,
2363 case_insensitive: false,
2364 };
2365 let matcher = Matcher::new(&op).unwrap();
2366 let result = apply(text, &op, &matcher, None, 0).unwrap();
2367 assert_eq!(result.text.unwrap(), "my_kebab_case\n");
2368 }
2369
2370 #[test]
2371 fn test_transform_camel_case_from_snake() {
2372 let text = "my_var_name\n";
2373 let op = Op::Transform {
2374 find: "my_var_name".to_string(),
2375 mode: TransformMode::CamelCase,
2376 regex: false,
2377 case_insensitive: false,
2378 };
2379 let matcher = Matcher::new(&op).unwrap();
2380 let result = apply(text, &op, &matcher, None, 0).unwrap();
2381 assert_eq!(result.text.unwrap(), "myVarName\n");
2382 }
2383
2384 #[test]
2385 fn test_transform_camel_case_from_kebab() {
2386 let text = "my-var-name\n";
2387 let op = Op::Transform {
2388 find: "my-var-name".to_string(),
2389 mode: TransformMode::CamelCase,
2390 regex: false,
2391 case_insensitive: false,
2392 };
2393 let matcher = Matcher::new(&op).unwrap();
2394 let result = apply(text, &op, &matcher, None, 0).unwrap();
2395 assert_eq!(result.text.unwrap(), "myVarName\n");
2396 }
2397
2398 #[test]
2403 fn test_surround_basic() {
2404 let text = "hello world\nfoo bar\n";
2405 let op = Op::Surround {
2406 find: "hello".to_string(),
2407 prefix: "<<".to_string(),
2408 suffix: ">>".to_string(),
2409 regex: false,
2410 case_insensitive: false,
2411 };
2412 let matcher = Matcher::new(&op).unwrap();
2413 let result = apply(text, &op, &matcher, None, 0).unwrap();
2414 assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
2415 assert_eq!(result.changes.len(), 1);
2416 assert_eq!(result.changes[0].line, 1);
2417 }
2418
2419 #[test]
2420 fn test_surround_multiple_lines() {
2421 let text = "foo line 1\nbar line 2\nfoo line 3\n";
2422 let op = Op::Surround {
2423 find: "foo".to_string(),
2424 prefix: "[".to_string(),
2425 suffix: "]".to_string(),
2426 regex: false,
2427 case_insensitive: false,
2428 };
2429 let matcher = Matcher::new(&op).unwrap();
2430 let result = apply(text, &op, &matcher, None, 0).unwrap();
2431 assert_eq!(
2432 result.text.unwrap(),
2433 "[foo line 1]\nbar line 2\n[foo line 3]\n"
2434 );
2435 assert_eq!(result.changes.len(), 2);
2436 }
2437
2438 #[test]
2439 fn test_surround_no_match() {
2440 let text = "hello world\n";
2441 let op = Op::Surround {
2442 find: "zzz".to_string(),
2443 prefix: "<".to_string(),
2444 suffix: ">".to_string(),
2445 regex: false,
2446 case_insensitive: false,
2447 };
2448 let matcher = Matcher::new(&op).unwrap();
2449 let result = apply(text, &op, &matcher, None, 0).unwrap();
2450 assert!(result.text.is_none());
2451 assert!(result.changes.is_empty());
2452 }
2453
2454 #[test]
2455 fn test_surround_empty_text() {
2456 let text = "";
2457 let op = Op::Surround {
2458 find: "anything".to_string(),
2459 prefix: "<".to_string(),
2460 suffix: ">".to_string(),
2461 regex: false,
2462 case_insensitive: false,
2463 };
2464 let matcher = Matcher::new(&op).unwrap();
2465 let result = apply(text, &op, &matcher, None, 0).unwrap();
2466 assert!(result.text.is_none());
2467 assert!(result.changes.is_empty());
2468 }
2469
2470 #[test]
2471 fn test_surround_with_regex() {
2472 let text = "fn main() {\n let x = 1;\n}\n";
2473 let op = Op::Surround {
2474 find: r"fn\s+\w+".to_string(),
2475 prefix: "/* ".to_string(),
2476 suffix: " */".to_string(),
2477 regex: true,
2478 case_insensitive: false,
2479 };
2480 let matcher = Matcher::new(&op).unwrap();
2481 let result = apply(text, &op, &matcher, None, 0).unwrap();
2482 assert_eq!(
2483 result.text.unwrap(),
2484 "/* fn main() { */\n let x = 1;\n}\n"
2485 );
2486 }
2487
2488 #[test]
2489 fn test_surround_case_insensitive() {
2490 let text = "Hello world\nhello world\nHELLO world\n";
2491 let op = Op::Surround {
2492 find: "hello".to_string(),
2493 prefix: "(".to_string(),
2494 suffix: ")".to_string(),
2495 regex: false,
2496 case_insensitive: true,
2497 };
2498 let matcher = Matcher::new(&op).unwrap();
2499 let result = apply(text, &op, &matcher, None, 0).unwrap();
2500 let output = result.text.unwrap();
2501 assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
2502 assert_eq!(result.changes.len(), 3);
2503 }
2504
2505 #[test]
2506 fn test_surround_crlf_preserved() {
2507 let text = "hello world\r\nfoo bar\r\n";
2508 let op = Op::Surround {
2509 find: "hello".to_string(),
2510 prefix: "[".to_string(),
2511 suffix: "]".to_string(),
2512 regex: false,
2513 case_insensitive: false,
2514 };
2515 let matcher = Matcher::new(&op).unwrap();
2516 let result = apply(text, &op, &matcher, None, 0).unwrap();
2517 assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
2518 }
2519
2520 #[test]
2521 fn test_surround_with_line_range() {
2522 let text = "foo\nfoo\nfoo\nfoo\n";
2523 let op = Op::Surround {
2524 find: "foo".to_string(),
2525 prefix: "<".to_string(),
2526 suffix: ">".to_string(),
2527 regex: false,
2528 case_insensitive: false,
2529 };
2530 let range = Some(LineRange {
2531 start: 2,
2532 end: Some(3),
2533 });
2534 let matcher = Matcher::new(&op).unwrap();
2535 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2536 assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
2537 assert_eq!(result.changes.len(), 2);
2538 }
2539
2540 #[test]
2541 fn test_surround_with_empty_prefix_and_suffix() {
2542 let text = "hello world\n";
2543 let op = Op::Surround {
2544 find: "hello".to_string(),
2545 prefix: String::new(),
2546 suffix: String::new(),
2547 regex: false,
2548 case_insensitive: false,
2549 };
2550 let matcher = Matcher::new(&op).unwrap();
2551 let result = apply(text, &op, &matcher, None, 0).unwrap();
2552 assert!(result.text.is_none());
2554 assert!(result.changes.is_empty());
2555 }
2556
2557 #[test]
2562 fn test_indent_basic() {
2563 let text = "hello\nworld\n";
2564 let op = Op::Indent {
2565 find: "hello".to_string(),
2566 amount: 4,
2567 use_tabs: false,
2568 regex: false,
2569 case_insensitive: false,
2570 };
2571 let matcher = Matcher::new(&op).unwrap();
2572 let result = apply(text, &op, &matcher, None, 0).unwrap();
2573 assert_eq!(result.text.unwrap(), " hello\nworld\n");
2574 assert_eq!(result.changes.len(), 1);
2575 }
2576
2577 #[test]
2578 fn test_indent_multiple_lines() {
2579 let text = "foo line 1\nbar line 2\nfoo line 3\n";
2580 let op = Op::Indent {
2581 find: "foo".to_string(),
2582 amount: 2,
2583 use_tabs: false,
2584 regex: false,
2585 case_insensitive: false,
2586 };
2587 let matcher = Matcher::new(&op).unwrap();
2588 let result = apply(text, &op, &matcher, None, 0).unwrap();
2589 assert_eq!(
2590 result.text.unwrap(),
2591 " foo line 1\nbar line 2\n foo line 3\n"
2592 );
2593 assert_eq!(result.changes.len(), 2);
2594 }
2595
2596 #[test]
2597 fn test_indent_with_tabs() {
2598 let text = "hello\nworld\n";
2599 let op = Op::Indent {
2600 find: "hello".to_string(),
2601 amount: 2,
2602 use_tabs: true,
2603 regex: false,
2604 case_insensitive: false,
2605 };
2606 let matcher = Matcher::new(&op).unwrap();
2607 let result = apply(text, &op, &matcher, None, 0).unwrap();
2608 assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
2609 }
2610
2611 #[test]
2612 fn test_indent_no_match() {
2613 let text = "hello world\n";
2614 let op = Op::Indent {
2615 find: "zzz".to_string(),
2616 amount: 4,
2617 use_tabs: false,
2618 regex: false,
2619 case_insensitive: false,
2620 };
2621 let matcher = Matcher::new(&op).unwrap();
2622 let result = apply(text, &op, &matcher, None, 0).unwrap();
2623 assert!(result.text.is_none());
2624 assert!(result.changes.is_empty());
2625 }
2626
2627 #[test]
2628 fn test_indent_empty_text() {
2629 let text = "";
2630 let op = Op::Indent {
2631 find: "anything".to_string(),
2632 amount: 4,
2633 use_tabs: false,
2634 regex: false,
2635 case_insensitive: false,
2636 };
2637 let matcher = Matcher::new(&op).unwrap();
2638 let result = apply(text, &op, &matcher, None, 0).unwrap();
2639 assert!(result.text.is_none());
2640 assert!(result.changes.is_empty());
2641 }
2642
2643 #[test]
2644 fn test_indent_zero_amount() {
2645 let text = "hello\n";
2646 let op = Op::Indent {
2647 find: "hello".to_string(),
2648 amount: 0,
2649 use_tabs: false,
2650 regex: false,
2651 case_insensitive: false,
2652 };
2653 let matcher = Matcher::new(&op).unwrap();
2654 let result = apply(text, &op, &matcher, None, 0).unwrap();
2655 assert!(result.text.is_none());
2657 assert!(result.changes.is_empty());
2658 }
2659
2660 #[test]
2661 fn test_indent_with_regex() {
2662 let text = "fn main() {\nlet x = 1;\n}\n";
2663 let op = Op::Indent {
2664 find: r"let\s+".to_string(),
2665 amount: 4,
2666 use_tabs: false,
2667 regex: true,
2668 case_insensitive: false,
2669 };
2670 let matcher = Matcher::new(&op).unwrap();
2671 let result = apply(text, &op, &matcher, None, 0).unwrap();
2672 assert_eq!(result.text.unwrap(), "fn main() {\n let x = 1;\n}\n");
2673 assert_eq!(result.changes.len(), 1);
2674 }
2675
2676 #[test]
2677 fn test_indent_case_insensitive() {
2678 let text = "Hello\nhello\nHELLO\n";
2679 let op = Op::Indent {
2680 find: "hello".to_string(),
2681 amount: 2,
2682 use_tabs: false,
2683 regex: false,
2684 case_insensitive: true,
2685 };
2686 let matcher = Matcher::new(&op).unwrap();
2687 let result = apply(text, &op, &matcher, None, 0).unwrap();
2688 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
2689 assert_eq!(result.changes.len(), 3);
2690 }
2691
2692 #[test]
2693 fn test_indent_crlf_preserved() {
2694 let text = "hello\r\nworld\r\n";
2695 let op = Op::Indent {
2696 find: "hello".to_string(),
2697 amount: 4,
2698 use_tabs: false,
2699 regex: false,
2700 case_insensitive: false,
2701 };
2702 let matcher = Matcher::new(&op).unwrap();
2703 let result = apply(text, &op, &matcher, None, 0).unwrap();
2704 assert_eq!(result.text.unwrap(), " hello\r\nworld\r\n");
2705 }
2706
2707 #[test]
2708 fn test_indent_with_line_range() {
2709 let text = "foo\nfoo\nfoo\nfoo\n";
2710 let op = Op::Indent {
2711 find: "foo".to_string(),
2712 amount: 4,
2713 use_tabs: false,
2714 regex: false,
2715 case_insensitive: false,
2716 };
2717 let range = Some(LineRange {
2718 start: 2,
2719 end: Some(3),
2720 });
2721 let matcher = Matcher::new(&op).unwrap();
2722 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2723 assert_eq!(result.text.unwrap(), "foo\n foo\n foo\nfoo\n");
2724 assert_eq!(result.changes.len(), 2);
2725 }
2726
2727 #[test]
2732 fn test_dedent_basic() {
2733 let text = " hello\nworld\n";
2734 let op = Op::Dedent {
2735 find: "hello".to_string(),
2736 amount: 4,
2737 use_tabs: false,
2738 regex: false,
2739 case_insensitive: false,
2740 };
2741 let matcher = Matcher::new(&op).unwrap();
2742 let result = apply(text, &op, &matcher, None, 0).unwrap();
2743 assert_eq!(result.text.unwrap(), "hello\nworld\n");
2744 assert_eq!(result.changes.len(), 1);
2745 }
2746
2747 #[test]
2748 fn test_dedent_partial() {
2749 let text = " hello\n";
2751 let op = Op::Dedent {
2752 find: "hello".to_string(),
2753 amount: 4,
2754 use_tabs: false,
2755 regex: false,
2756 case_insensitive: false,
2757 };
2758 let matcher = Matcher::new(&op).unwrap();
2759 let result = apply(text, &op, &matcher, None, 0).unwrap();
2760 assert_eq!(result.text.unwrap(), "hello\n");
2761 }
2762
2763 #[test]
2764 fn test_dedent_no_leading_spaces() {
2765 let text = "hello\n";
2767 let op = Op::Dedent {
2768 find: "hello".to_string(),
2769 amount: 4,
2770 use_tabs: false,
2771 regex: false,
2772 case_insensitive: false,
2773 };
2774 let matcher = Matcher::new(&op).unwrap();
2775 let result = apply(text, &op, &matcher, None, 0).unwrap();
2776 assert!(result.text.is_none());
2778 assert!(result.changes.is_empty());
2779 }
2780
2781 #[test]
2782 fn test_dedent_multiple_lines() {
2783 let text = " foo line 1\n bar line 2\n foo line 3\n";
2784 let op = Op::Dedent {
2785 find: "foo".to_string(),
2786 amount: 4,
2787 use_tabs: false,
2788 regex: false,
2789 case_insensitive: false,
2790 };
2791 let matcher = Matcher::new(&op).unwrap();
2792 let result = apply(text, &op, &matcher, None, 0).unwrap();
2793 assert_eq!(
2794 result.text.unwrap(),
2795 "foo line 1\n bar line 2\nfoo line 3\n"
2796 );
2797 assert_eq!(result.changes.len(), 2);
2798 }
2799
2800 #[test]
2801 fn test_dedent_no_match() {
2802 let text = " hello world\n";
2803 let op = Op::Dedent {
2804 find: "zzz".to_string(),
2805 amount: 4,
2806 use_tabs: false,
2807 regex: false,
2808 case_insensitive: false,
2809 };
2810 let matcher = Matcher::new(&op).unwrap();
2811 let result = apply(text, &op, &matcher, None, 0).unwrap();
2812 assert!(result.text.is_none());
2813 assert!(result.changes.is_empty());
2814 }
2815
2816 #[test]
2817 fn test_dedent_empty_text() {
2818 let text = "";
2819 let op = Op::Dedent {
2820 find: "anything".to_string(),
2821 amount: 4,
2822 use_tabs: false,
2823 regex: false,
2824 case_insensitive: false,
2825 };
2826 let matcher = Matcher::new(&op).unwrap();
2827 let result = apply(text, &op, &matcher, None, 0).unwrap();
2828 assert!(result.text.is_none());
2829 assert!(result.changes.is_empty());
2830 }
2831
2832 #[test]
2833 fn test_dedent_with_regex() {
2834 let text = " let x = 1;\n fn main() {\n";
2835 let op = Op::Dedent {
2836 find: r"let\s+".to_string(),
2837 amount: 4,
2838 use_tabs: false,
2839 regex: true,
2840 case_insensitive: false,
2841 };
2842 let matcher = Matcher::new(&op).unwrap();
2843 let result = apply(text, &op, &matcher, None, 0).unwrap();
2844 assert_eq!(result.text.unwrap(), "let x = 1;\n fn main() {\n");
2845 assert_eq!(result.changes.len(), 1);
2846 }
2847
2848 #[test]
2849 fn test_dedent_case_insensitive() {
2850 let text = " Hello\n hello\n HELLO\n";
2851 let op = Op::Dedent {
2852 find: "hello".to_string(),
2853 amount: 2,
2854 use_tabs: false,
2855 regex: false,
2856 case_insensitive: true,
2857 };
2858 let matcher = Matcher::new(&op).unwrap();
2859 let result = apply(text, &op, &matcher, None, 0).unwrap();
2860 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
2861 assert_eq!(result.changes.len(), 3);
2862 }
2863
2864 #[test]
2865 fn test_dedent_crlf_preserved() {
2866 let text = " hello\r\nworld\r\n";
2867 let op = Op::Dedent {
2868 find: "hello".to_string(),
2869 amount: 4,
2870 use_tabs: false,
2871 regex: false,
2872 case_insensitive: false,
2873 };
2874 let matcher = Matcher::new(&op).unwrap();
2875 let result = apply(text, &op, &matcher, None, 0).unwrap();
2876 assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
2877 }
2878
2879 #[test]
2880 fn test_dedent_with_line_range() {
2881 let text = " foo\n foo\n foo\n foo\n";
2882 let op = Op::Dedent {
2883 find: "foo".to_string(),
2884 amount: 4,
2885 use_tabs: false,
2886 regex: false,
2887 case_insensitive: false,
2888 };
2889 let range = Some(LineRange {
2890 start: 2,
2891 end: Some(3),
2892 });
2893 let matcher = Matcher::new(&op).unwrap();
2894 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2895 assert_eq!(result.text.unwrap(), " foo\nfoo\nfoo\n foo\n");
2896 assert_eq!(result.changes.len(), 2);
2897 }
2898
2899 #[test]
2900 fn test_dedent_only_removes_spaces_not_tabs() {
2901 let text = "\t\thello\n";
2903 let op = Op::Dedent {
2904 find: "hello".to_string(),
2905 amount: 4,
2906 use_tabs: false,
2907 regex: false,
2908 case_insensitive: false,
2909 };
2910 let matcher = Matcher::new(&op).unwrap();
2911 let result = apply(text, &op, &matcher, None, 0).unwrap();
2912 assert!(result.text.is_none());
2915 }
2916
2917 #[test]
2922 fn test_indent_then_dedent_roundtrip() {
2923 let original = "hello world\nfoo bar\n";
2924
2925 let indent_op = Op::Indent {
2927 find: "hello".to_string(),
2928 amount: 4,
2929 use_tabs: false,
2930 regex: false,
2931 case_insensitive: false,
2932 };
2933 let indent_matcher = Matcher::new(&indent_op).unwrap();
2934 let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
2935 let indented_text = indented.text.unwrap();
2936 assert_eq!(indented_text, " hello world\nfoo bar\n");
2937
2938 let dedent_op = Op::Dedent {
2940 find: "hello".to_string(),
2941 amount: 4,
2942 use_tabs: false,
2943 regex: false,
2944 case_insensitive: false,
2945 };
2946 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
2947 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
2948 assert_eq!(dedented.text.unwrap(), original);
2949 }
2950
2951 #[test]
2956 fn test_transform_undo_stores_original() {
2957 let text = "hello world\n";
2958 let op = Op::Transform {
2959 find: "hello".to_string(),
2960 mode: TransformMode::Upper,
2961 regex: false,
2962 case_insensitive: false,
2963 };
2964 let matcher = Matcher::new(&op).unwrap();
2965 let result = apply(text, &op, &matcher, None, 0).unwrap();
2966 assert_eq!(result.undo.unwrap().original_text, text);
2967 }
2968
2969 #[test]
2970 fn test_surround_undo_stores_original() {
2971 let text = "hello world\n";
2972 let op = Op::Surround {
2973 find: "hello".to_string(),
2974 prefix: "<".to_string(),
2975 suffix: ">".to_string(),
2976 regex: false,
2977 case_insensitive: false,
2978 };
2979 let matcher = Matcher::new(&op).unwrap();
2980 let result = apply(text, &op, &matcher, None, 0).unwrap();
2981 assert_eq!(result.undo.unwrap().original_text, text);
2982 }
2983
2984 #[test]
2985 fn test_indent_undo_stores_original() {
2986 let text = "hello world\n";
2987 let op = Op::Indent {
2988 find: "hello".to_string(),
2989 amount: 4,
2990 use_tabs: false,
2991 regex: false,
2992 case_insensitive: false,
2993 };
2994 let matcher = Matcher::new(&op).unwrap();
2995 let result = apply(text, &op, &matcher, None, 0).unwrap();
2996 assert_eq!(result.undo.unwrap().original_text, text);
2997 }
2998
2999 #[test]
3000 fn test_dedent_undo_stores_original() {
3001 let text = " hello world\n";
3002 let op = Op::Dedent {
3003 find: "hello".to_string(),
3004 amount: 4,
3005 use_tabs: false,
3006 regex: false,
3007 case_insensitive: false,
3008 };
3009 let matcher = Matcher::new(&op).unwrap();
3010 let result = apply(text, &op, &matcher, None, 0).unwrap();
3011 assert_eq!(result.undo.unwrap().original_text, text);
3012 }
3013
3014 #[test]
3019 fn test_transform_preserves_line_count() {
3020 let text = "hello\nworld\nfoo\n";
3021 let op = Op::Transform {
3022 find: "hello".to_string(),
3023 mode: TransformMode::Upper,
3024 regex: false,
3025 case_insensitive: false,
3026 };
3027 let matcher = Matcher::new(&op).unwrap();
3028 let result = apply(text, &op, &matcher, None, 0).unwrap();
3029 let output = result.text.unwrap();
3030 assert_eq!(text.lines().count(), output.lines().count());
3031 }
3032
3033 #[test]
3034 fn test_surround_preserves_line_count() {
3035 let text = "hello\nworld\nfoo\n";
3036 let op = Op::Surround {
3037 find: "hello".to_string(),
3038 prefix: "<".to_string(),
3039 suffix: ">".to_string(),
3040 regex: false,
3041 case_insensitive: false,
3042 };
3043 let matcher = Matcher::new(&op).unwrap();
3044 let result = apply(text, &op, &matcher, None, 0).unwrap();
3045 let output = result.text.unwrap();
3046 assert_eq!(text.lines().count(), output.lines().count());
3047 }
3048
3049 #[test]
3050 fn test_indent_preserves_line_count() {
3051 let text = "hello\nworld\nfoo\n";
3052 let op = Op::Indent {
3053 find: "hello".to_string(),
3054 amount: 4,
3055 use_tabs: false,
3056 regex: false,
3057 case_insensitive: false,
3058 };
3059 let matcher = Matcher::new(&op).unwrap();
3060 let result = apply(text, &op, &matcher, None, 0).unwrap();
3061 let output = result.text.unwrap();
3062 assert_eq!(text.lines().count(), output.lines().count());
3063 }
3064
3065 #[test]
3066 fn test_dedent_preserves_line_count() {
3067 let text = " hello\n world\n foo\n";
3068 let op = Op::Dedent {
3069 find: "hello".to_string(),
3070 amount: 4,
3071 use_tabs: false,
3072 regex: false,
3073 case_insensitive: false,
3074 };
3075 let matcher = Matcher::new(&op).unwrap();
3076 let result = apply(text, &op, &matcher, None, 0).unwrap();
3077 let output = result.text.unwrap();
3078 assert_eq!(text.lines().count(), output.lines().count());
3079 }
3080
3081 #[test]
3090 fn test_line_numbers_with_mixed_line_endings() {
3091 let text = "alpha\nbeta\r\ngamma match\ndelta\r\n";
3096 let op = Op::Replace {
3097 count: Default::default(),
3098 multiline: false,
3099 find: "match".to_string(),
3100 replace: "HIT".to_string(),
3101 regex: false,
3102 case_insensitive: false,
3103 };
3104 let matcher = Matcher::new(&op).unwrap();
3105 let result = apply(text, &op, &matcher, None, 0).unwrap();
3106 assert_eq!(result.changes.len(), 1);
3107 assert_eq!(
3108 result.changes[0].line, 3,
3109 "Line number must be 3 regardless of the mixed CR/LF / CRLF endings above it; got {}",
3110 result.changes[0].line
3111 );
3112 assert_eq!(result.changes[0].before, "gamma match");
3113 }
3114
3115 #[test]
3119 fn test_line_number_for_single_line_no_newline() {
3120 let text = "only line matches here";
3121 let op = Op::Replace {
3122 count: Default::default(),
3123 multiline: false,
3124 find: "matches".to_string(),
3125 replace: "OK".to_string(),
3126 regex: false,
3127 case_insensitive: false,
3128 };
3129 let matcher = Matcher::new(&op).unwrap();
3130 let result = apply(text, &op, &matcher, None, 0).unwrap();
3131 assert_eq!(result.changes.len(), 1);
3132 assert_eq!(result.changes[0].line, 1);
3133 }
3134
3135 #[test]
3138 fn test_first_line_is_one_not_zero() {
3139 let text = "match first\nother\nother\n";
3140 let op = Op::Replace {
3141 count: Default::default(),
3142 multiline: false,
3143 find: "match".to_string(),
3144 replace: "X".to_string(),
3145 regex: false,
3146 case_insensitive: false,
3147 };
3148 let matcher = Matcher::new(&op).unwrap();
3149 let result = apply(text, &op, &matcher, None, 0).unwrap();
3150 assert_eq!(
3151 result.changes[0].line, 1,
3152 "First-line change must be reported as line 1, not 0"
3153 );
3154 }
3155
3156 #[test]
3161 fn test_delete_reports_original_line_number() {
3162 let text = "keep1\ndelete_me\nkeep2\n";
3163 let op = Op::Delete {
3164 multiline: false,
3165 find: "delete_me".to_string(),
3166 regex: false,
3167 case_insensitive: false,
3168 };
3169 let matcher = Matcher::new(&op).unwrap();
3170 let result = apply(text, &op, &matcher, None, 0).unwrap();
3171 assert_eq!(result.changes.len(), 1);
3172 assert_eq!(
3173 result.changes[0].line, 2,
3174 "Deleted line's reported line must be its original position (2)"
3175 );
3176 assert_eq!(result.changes[0].before, "delete_me");
3177 assert_eq!(result.changes[0].after, None);
3178 }
3179
3180 #[test]
3187 fn test_delete_all_lines_produces_empty_file() {
3188 let text = "only line\n";
3189 let op = Op::Delete {
3190 multiline: false,
3191 find: "only".to_string(),
3192 regex: false,
3193 case_insensitive: false,
3194 };
3195 let matcher = Matcher::new(&op).unwrap();
3196 let result = apply(text, &op, &matcher, None, 0).unwrap();
3197 let output = result.text.unwrap();
3198 assert_eq!(
3199 output, "",
3200 "Deleting every line must yield an empty file, got {output:?}"
3201 );
3202 }
3203
3204 #[test]
3206 fn test_delete_all_lines_crlf_produces_empty_file() {
3207 let text = "only line\r\n";
3208 let op = Op::Delete {
3209 multiline: false,
3210 find: "only".to_string(),
3211 regex: false,
3212 case_insensitive: false,
3213 };
3214 let matcher = Matcher::new(&op).unwrap();
3215 let result = apply(text, &op, &matcher, None, 0).unwrap();
3216 let output = result.text.unwrap();
3217 assert_eq!(
3218 output, "",
3219 "Deleting every CRLF line must yield an empty file"
3220 );
3221 }
3222}
3223
3224#[cfg(test)]
3228mod proptests {
3229 use super::*;
3230 use crate::matcher::Matcher;
3231 use crate::operation::Op;
3232 use proptest::prelude::*;
3233
3234 fn arb_multiline_text() -> impl Strategy<Value = String> {
3236 prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
3237 }
3238
3239 fn arb_find_pattern() -> impl Strategy<Value = String> {
3241 "[a-zA-Z0-9]{1,8}"
3242 }
3243
3244 proptest! {
3245 #[test]
3250 fn prop_splice_equals_line_path(
3251 text in arb_multiline_text(),
3252 find in "[a-zA-Z0-9]{0,8}",
3255 replace in "[a-zA-Z0-9 ]{0,8}",
3256 case_insensitive in proptest::bool::ANY,
3257 ) {
3258 let op = Op::Replace {
3259 count: Default::default(),
3260 multiline: false,
3261 find,
3262 replace,
3263 regex: false,
3264 case_insensitive,
3265 };
3266 let matcher = Matcher::new(&op).unwrap();
3267 let spliced = apply(&text, &op, &matcher, None, 2).unwrap();
3268 let looped = apply(
3269 &text,
3270 &op,
3271 &matcher,
3272 Some(RangeSpec::Lines(LineRange { start: 1, end: None })),
3273 2,
3274 )
3275 .unwrap();
3276 prop_assert_eq!(spliced.text, looped.text);
3277 prop_assert_eq!(spliced.changes, looped.changes);
3278 }
3279
3280 #[test]
3283 fn prop_roundtrip_undo(
3284 text in arb_multiline_text(),
3285 find in arb_find_pattern(),
3286 replace in "[a-zA-Z0-9]{0,8}",
3287 ) {
3288 let op = Op::Replace {
3289 count: Default::default(),
3290 multiline: false,
3291 find: find.clone(),
3292 replace: replace.clone(),
3293 regex: false,
3294 case_insensitive: false,
3295 };
3296 let matcher = Matcher::new(&op).unwrap();
3297 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3298
3299 if let Some(undo) = &result.undo {
3300 prop_assert_eq!(&undo.original_text, &text);
3302 }
3303 if result.text.is_none() {
3305 prop_assert!(result.changes.is_empty());
3306 }
3307 }
3308
3309 #[test]
3311 fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
3312 let op = Op::Replace {
3315 count: Default::default(),
3316 multiline: false,
3317 find: "\x00\x00NOMATCH\x00\x00".to_string(),
3318 replace: "replacement".to_string(),
3319 regex: false,
3320 case_insensitive: false,
3321 };
3322 let matcher = Matcher::new(&op).unwrap();
3323 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3324 prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
3325 prop_assert!(result.changes.is_empty());
3326 prop_assert!(result.undo.is_none());
3327 }
3328
3329 #[test]
3331 fn prop_deterministic(
3332 text in arb_multiline_text(),
3333 find in arb_find_pattern(),
3334 replace in "[a-zA-Z0-9]{0,8}",
3335 ) {
3336 let op = Op::Replace {
3337 count: Default::default(),
3338 multiline: false,
3339 find,
3340 replace,
3341 regex: false,
3342 case_insensitive: false,
3343 };
3344 let matcher = Matcher::new(&op).unwrap();
3345 let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
3346 let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
3347 prop_assert_eq!(&r1.text, &r2.text);
3348 prop_assert_eq!(r1.changes.len(), r2.changes.len());
3349 }
3350
3351 #[test]
3353 fn prop_replace_preserves_line_count(
3354 text in arb_multiline_text(),
3355 find in arb_find_pattern(),
3356 replace in "[a-zA-Z0-9]{0,8}",
3357 ) {
3358 let op = Op::Replace {
3359 count: Default::default(),
3360 multiline: false,
3361 find,
3362 replace,
3363 regex: false,
3364 case_insensitive: false,
3365 };
3366 let matcher = Matcher::new(&op).unwrap();
3367 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3368 if let Some(ref output) = result.text {
3369 let input_lines = text.lines().count();
3370 let output_lines = output.lines().count();
3371 prop_assert_eq!(
3372 input_lines,
3373 output_lines,
3374 "Replace should preserve line count: input={} output={}",
3375 input_lines,
3376 output_lines
3377 );
3378 }
3379 }
3380
3381 #[test]
3384 fn prop_indent_dedent_roundtrip(
3385 amount in 1usize..=16,
3386 ) {
3387 let find = "marker".to_string();
3389 let text = "marker line one\nmarker line two\nmarker line three\n";
3390
3391 let indent_op = Op::Indent {
3392 find: find.clone(),
3393 amount,
3394 use_tabs: false,
3395 regex: false,
3396 case_insensitive: false,
3397 };
3398 let indent_matcher = Matcher::new(&indent_op).unwrap();
3399 let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
3400 let indented_text = indented.text.unwrap();
3401
3402 for line in indented_text.lines() {
3404 let leading = line.len() - line.trim_start_matches(' ').len();
3405 prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
3406 }
3407
3408 let dedent_op = Op::Dedent {
3409 find: find.clone(),
3410 amount,
3411 use_tabs: false,
3412 regex: false,
3413 case_insensitive: false,
3414 };
3415 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
3416 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
3417 prop_assert_eq!(dedented.text.unwrap(), text);
3418 }
3419
3420 #[test]
3423 fn prop_transform_upper_lower_roundtrip(
3424 find in "[a-z]{1,8}",
3425 ) {
3426 let text = format!("prefix {find} suffix\n");
3427
3428 let upper_op = Op::Transform {
3429 find: find.clone(),
3430 mode: crate::operation::TransformMode::Upper,
3431 regex: false,
3432 case_insensitive: false,
3433 };
3434 let upper_matcher = Matcher::new(&upper_op).unwrap();
3435 let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
3436
3437 if let Some(ref upper_text) = uppered.text {
3438 let upper_find = find.to_uppercase();
3439 let lower_op = Op::Transform {
3440 find: upper_find,
3441 mode: crate::operation::TransformMode::Lower,
3442 regex: false,
3443 case_insensitive: false,
3444 };
3445 let lower_matcher = Matcher::new(&lower_op).unwrap();
3446 let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
3447 prop_assert_eq!(lowered.text.unwrap(), text);
3448 }
3449 }
3450
3451 #[test]
3453 fn prop_surround_preserves_line_count(
3454 text in arb_multiline_text(),
3455 find in arb_find_pattern(),
3456 ) {
3457 let op = Op::Surround {
3458 find,
3459 prefix: "<<".to_string(),
3460 suffix: ">>".to_string(),
3461 regex: false,
3462 case_insensitive: false,
3463 };
3464 let matcher = Matcher::new(&op).unwrap();
3465 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3466 if let Some(ref output) = result.text {
3467 let input_lines = text.lines().count();
3468 let output_lines = output.lines().count();
3469 prop_assert_eq!(
3470 input_lines,
3471 output_lines,
3472 "Surround should preserve line count: input={} output={}",
3473 input_lines,
3474 output_lines
3475 );
3476 }
3477 }
3478
3479 #[test]
3481 fn prop_transform_preserves_line_count(
3482 text in arb_multiline_text(),
3483 find in arb_find_pattern(),
3484 ) {
3485 let op = Op::Transform {
3486 find,
3487 mode: crate::operation::TransformMode::Upper,
3488 regex: false,
3489 case_insensitive: false,
3490 };
3491 let matcher = Matcher::new(&op).unwrap();
3492 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3493 if let Some(ref output) = result.text {
3494 let input_lines = text.lines().count();
3495 let output_lines = output.lines().count();
3496 prop_assert_eq!(
3497 input_lines,
3498 output_lines,
3499 "Transform should preserve line count: input={} output={}",
3500 input_lines,
3501 output_lines
3502 );
3503 }
3504 }
3505
3506 #[test]
3508 fn prop_indent_preserves_line_count(
3509 text in arb_multiline_text(),
3510 find in arb_find_pattern(),
3511 amount in 1usize..=16,
3512 ) {
3513 let op = Op::Indent {
3514 find,
3515 amount,
3516 use_tabs: false,
3517 regex: false,
3518 case_insensitive: false,
3519 };
3520 let matcher = Matcher::new(&op).unwrap();
3521 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3522 if let Some(ref output) = result.text {
3523 let input_lines = text.lines().count();
3524 let output_lines = output.lines().count();
3525 prop_assert_eq!(
3526 input_lines,
3527 output_lines,
3528 "Indent should preserve line count: input={} output={}",
3529 input_lines,
3530 output_lines
3531 );
3532 }
3533 }
3534
3535 #[test]
3540 fn prop_change_lines_ascending_and_in_bounds(
3541 text in arb_multiline_text(),
3542 find in arb_find_pattern(),
3543 replace in "[a-zA-Z0-9]{0,8}",
3544 ) {
3545 let op = Op::Replace {
3546 count: Default::default(),
3547 multiline: false,
3548 find,
3549 replace,
3550 regex: false,
3551 case_insensitive: false,
3552 };
3553 let matcher = Matcher::new(&op).unwrap();
3554 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3555 let max_line = text.lines().count();
3556 let mut prev: usize = 0;
3557 for change in &result.changes {
3558 prop_assert!(change.line >= 1, "Line numbers must be 1-indexed, got {}", change.line);
3559 prop_assert!(
3560 change.line <= max_line,
3561 "Line {} out of input range (max {})",
3562 change.line,
3563 max_line
3564 );
3565 prop_assert!(
3566 change.line > prev,
3567 "Changes must be strictly ascending by line: prev={} current={}",
3568 prev,
3569 change.line
3570 );
3571 prev = change.line;
3572 }
3573 }
3574
3575 #[test]
3579 fn prop_delete_exact_line_count(
3580 find in arb_find_pattern(),
3584 match_count in 0usize..=8,
3585 nonmatch_count in 0usize..=8,
3586 ) {
3587 let match_lines: Vec<String> = (0..match_count)
3588 .map(|_| format!("-- {} --", find))
3589 .collect();
3590 let nonmatch_lines: Vec<String> = (0..nonmatch_count)
3593 .map(|_| "--- filler ---".to_string())
3594 .collect();
3595 for l in &nonmatch_lines {
3597 prop_assume!(!l.contains(&find));
3598 }
3599 let mut merged = Vec::with_capacity(match_count + nonmatch_count);
3601 let mut mi = match_lines.iter();
3602 let mut ni = nonmatch_lines.iter();
3603 loop {
3604 let m = mi.next();
3605 let n = ni.next();
3606 if m.is_none() && n.is_none() { break; }
3607 if let Some(m) = m { merged.push(m.clone()); }
3608 if let Some(n) = n { merged.push(n.clone()); }
3609 }
3610 if merged.is_empty() {
3611 return Ok(());
3613 }
3614 let text = merged.join("\n") + "\n";
3615
3616 let op = Op::Delete {
3617 multiline: false,
3618 find: find.clone(),
3619 regex: false,
3620 case_insensitive: false,
3621 };
3622 let matcher = Matcher::new(&op).unwrap();
3623 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3624
3625 let expected_deletions: usize = text.lines().filter(|l| l.contains(&find)).count();
3626 prop_assert_eq!(
3627 expected_deletions,
3628 match_count,
3629 "test construction bug: matcher-count must equal match_count"
3630 );
3631
3632 if expected_deletions == 0 {
3633 prop_assert!(result.text.is_none());
3634 prop_assert!(result.changes.is_empty());
3635 } else {
3636 let input_lines = text.lines().count();
3637 let output_lines = result.text.as_ref().unwrap().lines().count();
3638 prop_assert_eq!(
3639 input_lines - expected_deletions,
3640 output_lines,
3641 "Delete removed wrong number of lines: expected {} - {} = {}, got {}",
3642 input_lines,
3643 expected_deletions,
3644 input_lines - expected_deletions,
3645 output_lines
3646 );
3647 prop_assert_eq!(result.changes.len(), expected_deletions);
3648 }
3649 }
3650
3651 #[test]
3655 fn prop_crlf_majority_preserved(
3656 find in "[a-zA-Z]{1,6}",
3657 replace in "[a-zA-Z]{0,6}",
3658 ) {
3659 let text = format!(
3662 "line {find} one\r\n{find} middle\r\nanother {find} here\r\nending\r\n"
3663 );
3664 let op = Op::Replace {
3665 count: Default::default(),
3666 multiline: false,
3667 find: find.clone(),
3668 replace,
3669 regex: false,
3670 case_insensitive: false,
3671 };
3672 let matcher = Matcher::new(&op).unwrap();
3673 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3674 if let Some(ref output) = result.text {
3675 let crlf = output.matches("\r\n").count();
3676 let bare_lf = output.matches('\n').count() - crlf;
3677 prop_assert!(
3678 crlf > bare_lf,
3679 "CRLF majority lost: {crlf} CRLF vs {bare_lf} bare LF in {output:?}"
3680 );
3681 }
3682 }
3683
3684 #[test]
3689 fn prop_replace_change_count_matches_containing_lines(
3690 find in arb_find_pattern(),
3691 replace in "[a-zA-Z]{0,8}",
3692 ) {
3693 let text = format!(
3695 "head\n{find} one\nmiddle\n{find} two {find}\ntail\n"
3696 );
3697 let op = Op::Replace {
3698 count: Default::default(),
3699 multiline: false,
3700 find: find.clone(),
3701 replace,
3702 regex: false,
3703 case_insensitive: false,
3704 };
3705 let matcher = Matcher::new(&op).unwrap();
3706 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3707
3708 let expected: usize = text.lines().filter(|l| l.contains(&find)).count();
3709 prop_assert_eq!(result.changes.len(), expected);
3710 }
3711
3712 #[test]
3716 fn prop_no_trailing_newline_preserved(
3717 find in "[a-zA-Z]{1,6}",
3718 replace in "[a-zA-Z]{1,6}",
3719 ) {
3720 let text = format!("first line\nlast line with {find}");
3723 prop_assume!(!text.ends_with('\n'));
3724
3725 let op = Op::Replace {
3726 count: Default::default(),
3727 multiline: false,
3728 find,
3729 replace,
3730 regex: false,
3731 case_insensitive: false,
3732 };
3733 let matcher = Matcher::new(&op).unwrap();
3734 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3735 if let Some(ref output) = result.text {
3736 prop_assert!(
3737 !output.ends_with('\n'),
3738 "Spurious trailing newline added to {output:?} (input had none)"
3739 );
3740 }
3741 }
3742 }
3743}