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 !*regex
545 && *count != ReplaceCount::FirstPerLine
546 && !find.contains('\n')
547 && !find.contains('\r')
548 }
549 _ => false,
550 }
551}
552
553fn apply_spliced(text: &str, op: &Op, matcher: &Matcher, context_lines: usize) -> EngineOutput {
558 let replace = match op {
559 Op::Replace { replace, .. } => replace.as_str(),
560 _ => unreachable!("splice path only handles Replace"),
562 };
563
564 let mut spans = matcher.find_replacements(text, replace);
565 if let Some(limit) = replace_budget(op) {
566 spans.truncate(limit);
567 }
568 if spans.is_empty() {
569 return EngineOutput {
570 text: None,
571 changes: Vec::new(),
572 undo: None,
573 };
574 }
575
576 let bytes = text.as_bytes();
577 let mut out = String::with_capacity(text.len());
578 let mut changes: Vec<Change> = Vec::new();
579 let mut last_end = 0usize; let mut line_num = 1usize; let mut scan = 0usize; let mut i = 0;
584 while i < spans.len() {
585 let group_first = spans[i].start;
586 line_num += memchr::memchr_iter(b'\n', &bytes[scan..group_first]).count();
588 scan = group_first;
589
590 let line_begin = memchr::memrchr(b'\n', &bytes[..group_first]).map_or(0, |p| p + 1);
593 let line_end =
594 memchr::memchr(b'\n', &bytes[group_first..]).map_or(text.len(), |p| group_first + p);
595 let content_end = if line_end > line_begin && bytes[line_end - 1] == b'\r' {
596 line_end - 1
597 } else {
598 line_end
599 };
600
601 let before_line = &text[line_begin..content_end];
603 let mut after_line = String::with_capacity(before_line.len());
604 let mut line_cursor = line_begin;
605 while i < spans.len() && spans[i].start < line_end {
606 let span = &spans[i];
607 after_line.push_str(&text[line_cursor..span.start]);
608 after_line.push_str(&span.replacement);
609 out.push_str(&text[last_end..span.start]);
610 out.push_str(&span.replacement);
611 line_cursor = span.end;
612 last_end = span.end;
613 i += 1;
614 }
615 after_line.push_str(&text[line_cursor..content_end]);
616
617 let context = if context_lines == 0 || changes.len() >= MAX_CONTEXTED_CHANGES {
618 None
619 } else {
620 Some(splice_line_context(
621 text,
622 line_begin,
623 line_end,
624 context_lines,
625 ))
626 };
627 changes.push(Change {
628 line: line_num,
629 before: before_line.to_string(),
630 after: Some(after_line),
631 context,
632 });
633 }
634 out.push_str(&text[last_end..]);
635
636 EngineOutput {
637 text: Some(out),
638 changes,
639 undo: Some(UndoEntry {
640 original_text: text.to_string(),
641 }),
642 }
643}
644
645fn splice_line_context(
649 text: &str,
650 line_begin: usize,
651 line_end: usize,
652 context_lines: usize,
653) -> ChangeContext {
654 let bytes = text.as_bytes();
655 let strip_cr = |s: &str| s.strip_suffix('\r').unwrap_or(s).to_string();
656
657 let mut before = Vec::new();
658 let mut end = line_begin; for _ in 0..context_lines {
660 if end == 0 {
661 break;
662 }
663 let term = end - 1; let begin = memchr::memrchr(b'\n', &bytes[..term]).map_or(0, |p| p + 1);
667 let content = &text[begin..term];
668 before.push(strip_cr(content));
669 end = begin;
670 }
671 before.reverse();
672
673 let mut after = Vec::new();
674 let mut begin = match line_end {
675 e if e >= text.len() => text.len(),
676 e => e + 1, };
678 for _ in 0..context_lines {
679 if begin >= text.len() {
680 break;
681 }
682 let term = memchr::memchr(b'\n', &bytes[begin..]).map_or(text.len(), |p| begin + p);
683 after.push(strip_cr(&text[begin..term]));
684 begin = if term >= text.len() {
685 text.len()
686 } else {
687 term + 1
688 };
689 }
690
691 ChangeContext { before, after }
692}
693
694fn dispatch_op(op: &Op, cx: &LineCtx, budget: &mut Option<usize>) -> LineAction {
699 match op {
700 Op::Replace { replace, count, .. } => apply_replace(cx, replace, *count, budget),
701 Op::Delete { .. } => apply_delete(cx),
702 Op::InsertAfter { content, .. } => apply_insert_after(cx, content),
703 Op::InsertBefore { content, .. } => apply_insert_before(cx, content),
704 Op::ReplaceLine { content, .. } => apply_replace_line(cx, content),
705 Op::Transform { mode, .. } => apply_transform_op(cx, *mode),
706 Op::Surround { prefix, suffix, .. } => apply_surround(cx, prefix, suffix),
707 Op::Indent {
708 amount, use_tabs, ..
709 } => apply_indent(cx, *amount, *use_tabs),
710 Op::Dedent {
711 amount, use_tabs, ..
712 } => apply_dedent(cx, *amount, *use_tabs),
713 }
714}
715
716#[derive(Debug, PartialEq, Eq)]
718pub struct StreamedLine {
719 pub lines: Vec<String>,
722 pub changed: bool,
724}
725
726pub struct LineProcessor<'a> {
737 op: &'a Op,
738 matcher: &'a Matcher,
739 range_filter: RangeFilter,
740 budget: Option<usize>,
741 line_num: usize,
742}
743
744impl<'a> LineProcessor<'a> {
745 pub fn new(
746 op: &'a Op,
747 matcher: &'a Matcher,
748 range: Option<RangeSpec>,
749 ) -> Result<Self, RipsedError> {
750 if op.is_multiline() {
751 return Err(RipsedError::invalid_request(
752 "multiline operations cannot be streamed",
753 "multiline patterns match against the whole buffer; buffer the input instead",
754 ));
755 }
756 Ok(Self {
757 op,
758 matcher,
759 range_filter: RangeFilter::new(range.as_ref())?,
760 budget: replace_budget(op),
761 line_num: 0,
762 })
763 }
764
765 pub fn process_line(&mut self, line: &str) -> StreamedLine {
767 self.line_num += 1;
768 if !self.range_filter.admits(self.line_num, line) {
769 return StreamedLine {
770 lines: vec![line.to_string()],
771 changed: false,
772 };
773 }
774 let lines_slice = [line];
777 let cx = LineCtx {
778 line,
779 line_num: self.line_num,
780 matcher: self.matcher,
781 lines: &lines_slice,
782 idx: 0,
783 context_lines: 0,
784 line_sep: "\n",
785 };
786 match dispatch_op(self.op, &cx, &mut self.budget) {
787 LineAction::Unchanged => StreamedLine {
788 lines: vec![line.to_string()],
789 changed: false,
790 },
791 LineAction::Replaced { new_line, .. } => StreamedLine {
792 lines: vec![new_line],
793 changed: true,
794 },
795 LineAction::Deleted { .. } => StreamedLine {
796 lines: Vec::new(),
797 changed: true,
798 },
799 LineAction::InsertedAfter { content, .. } => StreamedLine {
800 lines: vec![line.to_string(), content],
801 changed: true,
802 },
803 LineAction::InsertedBefore { content, .. } => StreamedLine {
804 lines: vec![content, line.to_string()],
805 changed: true,
806 },
807 }
808 }
809}
810
811fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
813 match matcher {
814 Matcher::Literal { pattern, .. } => {
815 line.replace(pattern.as_str(), &transform_text(pattern, mode))
816 }
817 Matcher::Regex { re, .. } => {
818 let result = re.replace_all(line, |caps: ®ex::Captures| {
819 transform_text(&caps[0], mode)
820 });
821 result.into_owned()
822 }
823 }
824}
825
826fn transform_text(text: &str, mode: TransformMode) -> String {
828 match mode {
829 TransformMode::Upper => text.to_uppercase(),
830 TransformMode::Lower => text.to_lowercase(),
831 TransformMode::Title => {
832 let mut result = String::with_capacity(text.len());
833 let mut capitalize_next = true;
834 for ch in text.chars() {
835 if ch.is_whitespace() || ch == '_' || ch == '-' {
836 result.push(ch);
837 capitalize_next = true;
838 } else if capitalize_next {
839 for upper in ch.to_uppercase() {
840 result.push(upper);
841 }
842 capitalize_next = false;
843 } else {
844 result.push(ch);
845 }
846 }
847 result
848 }
849 TransformMode::SnakeCase => {
850 let mut result = String::with_capacity(text.len() + 4);
851 let mut prev_was_lower = false;
852 for ch in text.chars() {
853 if ch.is_uppercase() {
854 if prev_was_lower {
855 result.push('_');
856 }
857 for lower in ch.to_lowercase() {
858 result.push(lower);
859 }
860 prev_was_lower = false;
861 } else if ch == '-' || ch == ' ' {
862 result.push('_');
863 prev_was_lower = false;
864 } else {
865 result.push(ch);
866 prev_was_lower = ch.is_lowercase();
867 }
868 }
869 result
870 }
871 TransformMode::CamelCase => {
872 let mut result = String::with_capacity(text.len());
873 let mut capitalize_next = false;
874 let mut first = true;
875 for ch in text.chars() {
876 if ch == '_' || ch == '-' || ch == ' ' {
877 capitalize_next = true;
878 } else if capitalize_next {
879 for upper in ch.to_uppercase() {
880 result.push(upper);
881 }
882 capitalize_next = false;
883 } else if first {
884 for lower in ch.to_lowercase() {
885 result.push(lower);
886 }
887 first = false;
888 } else {
889 result.push(ch);
890 first = false;
891 }
892 }
893 result
894 }
895 }
896}
897
898fn dedent_line(line: &str, amount: usize, use_tabs: bool) -> String {
901 let ch = if use_tabs { '\t' } else { ' ' };
902 let leading = line.len() - line.trim_start_matches(ch).len();
903 let remove = leading.min(amount);
904 line[remove..].to_string()
905}
906
907fn apply_multiline(text: &str, op: &Op, matcher: &Matcher, context_lines: usize) -> EngineOutput {
919 let (replacement, is_delete) = match op {
922 Op::Replace { replace, .. } => (replace.as_str(), false),
923 _ => ("", true),
924 };
925
926 let mut spans = matcher.find_replacements(text, replacement);
927 if let Some(limit) = replace_budget(op) {
930 spans.truncate(limit);
931 }
932 if spans.is_empty() {
933 return EngineOutput {
934 text: None,
935 changes: Vec::new(),
936 undo: None,
937 };
938 }
939
940 let lines: Vec<&str> = text.lines().collect();
941 let mut out = String::with_capacity(text.len());
942 let mut changes = Vec::with_capacity(spans.len());
943 let mut last_end = 0usize;
944
945 for MatchSpan {
946 start,
947 end,
948 replacement,
949 } in spans
950 {
951 out.push_str(&text[last_end..start]);
952 let before = &text[start..end];
953 let start_line_idx = text[..start].matches('\n').count();
954 let end_line_idx = start_line_idx + before.matches('\n').count();
955 changes.push(Change {
956 line: start_line_idx + 1,
957 before: before.to_string(),
958 after: if is_delete {
959 None
960 } else {
961 Some(replacement.clone())
962 },
963 context: if context_lines == 0 {
964 None
965 } else {
966 Some(build_span_context(
967 &lines,
968 start_line_idx,
969 end_line_idx,
970 context_lines,
971 ))
972 },
973 });
974 out.push_str(&replacement);
975 last_end = end;
976 }
977 out.push_str(&text[last_end..]);
978
979 EngineOutput {
980 text: Some(out),
981 changes,
982 undo: Some(UndoEntry {
983 original_text: text.to_string(),
984 }),
985 }
986}
987
988fn build_span_context(
992 lines: &[&str],
993 start_idx: usize,
994 end_idx: usize,
995 context_lines: usize,
996) -> ChangeContext {
997 let before_start = start_idx.saturating_sub(context_lines);
998 let before = lines[before_start..start_idx.min(lines.len())]
999 .iter()
1000 .map(|s| s.to_string())
1001 .collect();
1002 let after_start = (end_idx + 1).min(lines.len());
1003 let after_end = (end_idx + 1 + context_lines).min(lines.len());
1004 let after = lines[after_start..after_end]
1005 .iter()
1006 .map(|s| s.to_string())
1007 .collect();
1008 ChangeContext { before, after }
1009}
1010
1011fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
1012 let start = idx.saturating_sub(context_lines);
1013 let end = (idx + context_lines + 1).min(lines.len());
1014
1015 let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
1016 let after = if idx + 1 < end {
1017 lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
1018 } else {
1019 vec![]
1020 };
1021
1022 ChangeContext { before, after }
1023}
1024
1025pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
1027 OpResult {
1028 operation_index,
1029 files: if changes.is_empty() {
1030 vec![]
1031 } else {
1032 vec![FileChanges {
1033 path: path.to_string(),
1034 changes,
1035 }]
1036 },
1037 }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043 use crate::matcher::Matcher;
1044
1045 #[test]
1046 fn test_simple_replace() {
1047 let text = "hello world\nfoo bar\nhello again\n";
1048 let op = Op::Replace {
1049 count: Default::default(),
1050 multiline: false,
1051 find: "hello".to_string(),
1052 replace: "hi".to_string(),
1053 regex: false,
1054 case_insensitive: false,
1055 };
1056 let matcher = Matcher::new(&op).unwrap();
1057 let result = apply(text, &op, &matcher, None, 2).unwrap();
1058 assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
1059 assert_eq!(result.changes.len(), 2);
1060 }
1061
1062 #[test]
1063 fn test_delete_lines() {
1064 let text = "keep\ndelete me\nkeep too\n";
1065 let op = Op::Delete {
1066 multiline: false,
1067 find: "delete".to_string(),
1068 regex: false,
1069 case_insensitive: false,
1070 };
1071 let matcher = Matcher::new(&op).unwrap();
1072 let result = apply(text, &op, &matcher, None, 0).unwrap();
1073 assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
1074 }
1075
1076 #[test]
1077 fn test_no_changes() {
1078 let text = "nothing matches here\n";
1079 let op = Op::Replace {
1080 count: Default::default(),
1081 multiline: false,
1082 find: "zzz".to_string(),
1083 replace: "aaa".to_string(),
1084 regex: false,
1085 case_insensitive: false,
1086 };
1087 let matcher = Matcher::new(&op).unwrap();
1088 let result = apply(text, &op, &matcher, None, 0).unwrap();
1089 assert!(result.text.is_none());
1090 assert!(result.changes.is_empty());
1091 }
1092
1093 #[test]
1094 fn test_line_range() {
1095 let text = "line1\nline2\nline3\nline4\n";
1096 let op = Op::Replace {
1097 count: Default::default(),
1098 multiline: false,
1099 find: "line".to_string(),
1100 replace: "row".to_string(),
1101 regex: false,
1102 case_insensitive: false,
1103 };
1104 let range = Some(LineRange {
1105 start: 2,
1106 end: Some(3),
1107 });
1108 let matcher = Matcher::new(&op).unwrap();
1109 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
1110 assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
1111 }
1112
1113 #[test]
1118 fn test_crlf_replace_preserves_crlf() {
1119 let text = "hello world\r\nfoo bar\r\nhello again\r\n";
1120 let op = Op::Replace {
1121 count: Default::default(),
1122 multiline: false,
1123 find: "hello".to_string(),
1124 replace: "hi".to_string(),
1125 regex: false,
1126 case_insensitive: false,
1127 };
1128 let matcher = Matcher::new(&op).unwrap();
1129 let result = apply(text, &op, &matcher, None, 0).unwrap();
1130 assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
1131 }
1132
1133 #[test]
1134 fn test_crlf_delete_preserves_crlf() {
1135 let text = "keep\r\ndelete me\r\nkeep too\r\n";
1136 let op = Op::Delete {
1137 multiline: false,
1138 find: "delete".to_string(),
1139 regex: false,
1140 case_insensitive: false,
1141 };
1142 let matcher = Matcher::new(&op).unwrap();
1143 let result = apply(text, &op, &matcher, None, 0).unwrap();
1144 assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
1145 }
1146
1147 #[test]
1148 fn test_crlf_no_trailing_newline() {
1149 let text = "hello world\r\nfoo bar";
1150 let op = Op::Replace {
1151 count: Default::default(),
1152 multiline: false,
1153 find: "hello".to_string(),
1154 replace: "hi".to_string(),
1155 regex: false,
1156 case_insensitive: false,
1157 };
1158 let matcher = Matcher::new(&op).unwrap();
1159 let result = apply(text, &op, &matcher, None, 0).unwrap();
1160 let output = result.text.unwrap();
1161 assert_eq!(output, "hi world\r\nfoo bar");
1162 assert!(!output.ends_with("\r\n"));
1164 }
1165
1166 #[test]
1167 fn test_crlf_insert_after_metadata_uses_crlf() {
1168 let text = "alpha\r\nbeta\r\n";
1169 let op = Op::InsertAfter {
1170 find: "alpha".to_string(),
1171 content: "inserted".to_string(),
1172 regex: false,
1173 case_insensitive: false,
1174 };
1175 let matcher = Matcher::new(&op).unwrap();
1176 let result = apply(text, &op, &matcher, None, 0).unwrap();
1177
1178 let output = result.text.unwrap();
1179 assert_eq!(output, "alpha\r\ninserted\r\nbeta\r\n");
1180
1181 let after = result.changes[0].after.as_deref().unwrap();
1184 assert_eq!(after, "alpha\r\ninserted");
1185 assert!(
1186 output.contains(after),
1187 "metadata {after:?} must appear verbatim in output {output:?}"
1188 );
1189 }
1190
1191 #[test]
1192 fn test_crlf_insert_before_metadata_uses_crlf() {
1193 let text = "alpha\r\nbeta\r\n";
1194 let op = Op::InsertBefore {
1195 find: "beta".to_string(),
1196 content: "inserted".to_string(),
1197 regex: false,
1198 case_insensitive: false,
1199 };
1200 let matcher = Matcher::new(&op).unwrap();
1201 let result = apply(text, &op, &matcher, None, 0).unwrap();
1202
1203 let output = result.text.unwrap();
1204 assert_eq!(output, "alpha\r\ninserted\r\nbeta\r\n");
1205
1206 let after = result.changes[0].after.as_deref().unwrap();
1207 assert_eq!(after, "inserted\r\nbeta");
1208 assert!(
1209 output.contains(after),
1210 "metadata {after:?} must appear verbatim in output {output:?}"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_lf_insert_after_metadata_uses_lf() {
1216 let text = "alpha\nbeta\n";
1218 let op = Op::InsertAfter {
1219 find: "alpha".to_string(),
1220 content: "inserted".to_string(),
1221 regex: false,
1222 case_insensitive: false,
1223 };
1224 let matcher = Matcher::new(&op).unwrap();
1225 let result = apply(text, &op, &matcher, None, 0).unwrap();
1226 let after = result.changes[0].after.as_deref().unwrap();
1227 assert_eq!(after, "alpha\ninserted");
1228 }
1229
1230 fn multiline_replace_op(find: &str, replace: &str, regex: bool) -> Op {
1233 Op::Replace {
1234 count: Default::default(),
1235 find: find.to_string(),
1236 replace: replace.to_string(),
1237 regex,
1238 case_insensitive: false,
1239 multiline: true,
1240 }
1241 }
1242
1243 fn multiline_delete_op(find: &str, regex: bool) -> Op {
1244 Op::Delete {
1245 find: find.to_string(),
1246 regex,
1247 case_insensitive: false,
1248 multiline: true,
1249 }
1250 }
1251
1252 #[test]
1253 fn test_multiline_literal_replace_across_lines() {
1254 let text = "fn old(\n x: u32,\n) {}\n";
1255 let op = multiline_replace_op("old(\n x: u32,\n)", "new(x: u32)", false);
1256 let matcher = Matcher::new(&op).unwrap();
1257 let result = apply(text, &op, &matcher, None, 0).unwrap();
1258 assert_eq!(result.text.unwrap(), "fn new(x: u32) {}\n");
1259 assert_eq!(result.changes.len(), 1);
1260 assert_eq!(result.changes[0].line, 1);
1261 assert_eq!(result.changes[0].before, "old(\n x: u32,\n)");
1262 assert_eq!(result.changes[0].after.as_deref(), Some("new(x: u32)"));
1263 }
1264
1265 #[test]
1266 fn test_multiline_regex_captures_across_lines() {
1267 let text = "alpha\nbeta\ngamma\n";
1268 let op = multiline_replace_op(r"(\w+)\n(\w+)\n", "$2\n$1\n", true);
1270 let matcher = Matcher::new(&op).unwrap();
1271 let result = apply(text, &op, &matcher, None, 0).unwrap();
1272 assert_eq!(result.text.unwrap(), "beta\nalpha\ngamma\n");
1273 assert_eq!(result.changes[0].after.as_deref(), Some("beta\nalpha\n"));
1274 }
1275
1276 #[test]
1277 fn test_multiline_delete_removes_span_not_lines() {
1278 let text = "keep [START]\ndoomed\n[END] keep\n";
1279 let op = multiline_delete_op("[START]\ndoomed\n[END]", false);
1280 let matcher = Matcher::new(&op).unwrap();
1281 let result = apply(text, &op, &matcher, None, 0).unwrap();
1282 assert_eq!(result.text.unwrap(), "keep keep\n");
1284 assert_eq!(result.changes[0].after, None);
1285 assert_eq!(result.changes[0].before, "[START]\ndoomed\n[END]");
1286 }
1287
1288 #[test]
1289 fn test_multiline_crlf_metadata_matches_output_bytes() {
1290 let text = "alpha\r\nbeta\r\ngamma\r\n";
1291 let op = multiline_replace_op("alpha\r\nbeta", "one\r\ntwo", false);
1292 let matcher = Matcher::new(&op).unwrap();
1293 let result = apply(text, &op, &matcher, None, 0).unwrap();
1294 let output = result.text.unwrap();
1295 assert_eq!(output, "one\r\ntwo\r\ngamma\r\n");
1296 let change = &result.changes[0];
1297 assert_eq!(change.before, "alpha\r\nbeta");
1298 let after = change.after.as_deref().unwrap();
1299 assert_eq!(after, "one\r\ntwo");
1300 assert!(
1301 output.contains(after),
1302 "metadata {after:?} must appear verbatim in output {output:?}"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_multiline_match_at_eof_without_trailing_newline() {
1308 let text = "head\ntail";
1309 let op = multiline_replace_op("head\ntail", "joined", false);
1310 let matcher = Matcher::new(&op).unwrap();
1311 let result = apply(text, &op, &matcher, None, 0).unwrap();
1312 let output = result.text.unwrap();
1313 assert_eq!(output, "joined");
1314 assert!(!output.ends_with('\n'));
1315 }
1316
1317 #[test]
1318 fn test_multiline_preserves_untouched_separators() {
1319 let text = "a\r\nMARK\nb\n";
1322 let op = multiline_replace_op("MARK", "X", false);
1323 let matcher = Matcher::new(&op).unwrap();
1324 let result = apply(text, &op, &matcher, None, 0).unwrap();
1325 assert_eq!(result.text.unwrap(), "a\r\nX\nb\n");
1326 }
1327
1328 #[test]
1329 fn test_multiline_with_line_range_is_rejected() {
1330 let op = multiline_replace_op("a", "b", false);
1331 let matcher = Matcher::new(&op).unwrap();
1332 let range = LineRange {
1333 start: 1,
1334 end: Some(2),
1335 };
1336 let err = apply("a\n", &op, &matcher, Some(RangeSpec::Lines(range)), 0).unwrap_err();
1337 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1338 }
1339
1340 #[test]
1341 fn test_multiline_change_line_numbers_ascending_and_correct() {
1342 let text = "x\nfoo\nx\nfoo\nx\nfoo\n";
1343 let op = multiline_replace_op("foo\nx", "bar\nx", false);
1344 let matcher = Matcher::new(&op).unwrap();
1345 let result = apply(text, &op, &matcher, None, 0).unwrap();
1346 let line_numbers: Vec<usize> = result.changes.iter().map(|c| c.line).collect();
1348 assert_eq!(line_numbers, vec![2, 4]);
1349 assert_eq!(result.text.unwrap(), "x\nbar\nx\nbar\nx\nfoo\n");
1350 }
1351
1352 #[test]
1353 fn test_multiline_no_match_returns_none() {
1354 let op = multiline_replace_op("absent\npattern", "x", false);
1355 let matcher = Matcher::new(&op).unwrap();
1356 let result = apply("some\ntext\n", &op, &matcher, None, 0).unwrap();
1357 assert!(result.text.is_none());
1358 assert!(result.changes.is_empty());
1359 assert!(result.undo.is_none());
1360 }
1361
1362 #[test]
1363 fn test_multiline_delete_everything_yields_empty_string() {
1364 let text = "all\ngone\n";
1365 let op = multiline_delete_op("all\ngone\n", false);
1366 let matcher = Matcher::new(&op).unwrap();
1367 let result = apply(text, &op, &matcher, None, 0).unwrap();
1368 assert_eq!(result.text.unwrap(), "");
1369 }
1370
1371 #[test]
1372 fn test_multiline_undo_roundtrip() {
1373 let text = "one\ntwo\nthree\n";
1374 let op = multiline_replace_op("one\ntwo", "1\n2", false);
1375 let matcher = Matcher::new(&op).unwrap();
1376 let result = apply(text, &op, &matcher, None, 0).unwrap();
1377 assert_eq!(result.text.unwrap(), "1\n2\nthree\n");
1378 assert_eq!(result.undo.unwrap().original_text, text);
1379 }
1380
1381 #[test]
1382 fn test_multiline_output_equals_matcher_replace() {
1383 let text = "aaa\nbbb aaa\nccc\n";
1386 let op = multiline_replace_op("aa", "Z", false);
1387 let matcher = Matcher::new(&op).unwrap();
1388 let result = apply(text, &op, &matcher, None, 0).unwrap();
1389 assert_eq!(
1390 result.text.unwrap(),
1391 matcher.replace(text, "Z").unwrap(),
1392 "span splicing must match replace_all output"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_multiline_span_context() {
1398 let text = "ctx1\nctx2\nA\nB\nctx3\nctx4\n";
1399 let op = multiline_replace_op("A\nB", "AB", false);
1400 let matcher = Matcher::new(&op).unwrap();
1401 let result = apply(text, &op, &matcher, None, 1).unwrap();
1402 let ctx = result.changes[0].context.as_ref().unwrap();
1403 assert_eq!(ctx.before, vec!["ctx2".to_string()]);
1404 assert_eq!(ctx.after, vec!["ctx3".to_string()]);
1405 }
1406
1407 fn counted_replace_op(find: &str, replace: &str, count: ReplaceCount) -> Op {
1410 Op::Replace {
1411 find: find.to_string(),
1412 replace: replace.to_string(),
1413 regex: false,
1414 case_insensitive: false,
1415 multiline: false,
1416 count,
1417 }
1418 }
1419
1420 #[test]
1421 fn test_count_first_per_line_replaces_one_per_line() {
1422 let text = "a a a\nx\na a\n";
1423 let op = counted_replace_op("a", "B", ReplaceCount::FirstPerLine);
1424 let matcher = Matcher::new(&op).unwrap();
1425 let result = apply(text, &op, &matcher, None, 0).unwrap();
1426 assert_eq!(result.text.unwrap(), "B a a\nx\nB a\n");
1427 assert_eq!(result.changes.len(), 2);
1428 }
1429
1430 #[test]
1431 fn test_count_first_in_file_replaces_only_first_occurrence() {
1432 let text = "a a\na\na\n";
1433 let op = counted_replace_op("a", "B", ReplaceCount::FirstInFile);
1434 let matcher = Matcher::new(&op).unwrap();
1435 let result = apply(text, &op, &matcher, None, 0).unwrap();
1436 assert_eq!(result.text.unwrap(), "B a\na\na\n");
1437 assert_eq!(result.changes.len(), 1);
1438 assert_eq!(result.changes[0].line, 1);
1439 }
1440
1441 #[test]
1442 fn test_count_max_spans_lines_and_counts_occurrences() {
1443 let text = "a a\na a\na\n";
1446 let op = counted_replace_op("a", "B", ReplaceCount::Max(3));
1447 let matcher = Matcher::new(&op).unwrap();
1448 let result = apply(text, &op, &matcher, None, 0).unwrap();
1449 assert_eq!(result.text.unwrap(), "B B\nB a\na\n");
1450 assert_eq!(result.changes.len(), 2);
1451 }
1452
1453 #[test]
1454 fn test_count_all_is_default_behavior() {
1455 let text = "a a\na\n";
1456 let op = counted_replace_op("a", "B", ReplaceCount::All);
1457 let matcher = Matcher::new(&op).unwrap();
1458 let result = apply(text, &op, &matcher, None, 0).unwrap();
1459 assert_eq!(result.text.unwrap(), "B B\nB\n");
1460 }
1461
1462 #[test]
1463 fn test_count_first_per_line_with_regex_captures() {
1464 let text = "x1 x2 x3\n";
1465 let op = Op::Replace {
1466 find: r"x(\d)".to_string(),
1467 replace: "y$1".to_string(),
1468 regex: true,
1469 case_insensitive: false,
1470 multiline: false,
1471 count: ReplaceCount::FirstPerLine,
1472 };
1473 let matcher = Matcher::new(&op).unwrap();
1474 let result = apply(text, &op, &matcher, None, 0).unwrap();
1475 assert_eq!(result.text.unwrap(), "y1 x2 x3\n");
1476 }
1477
1478 #[test]
1479 fn test_count_max_in_multiline_mode_truncates_spans() {
1480 let text = "a\na\na\n";
1481 let op = Op::Replace {
1482 find: "a\n".to_string(),
1483 replace: "B\n".to_string(),
1484 regex: false,
1485 case_insensitive: false,
1486 multiline: true,
1487 count: ReplaceCount::Max(2),
1488 };
1489 let matcher = Matcher::new(&op).unwrap();
1490 let result = apply(text, &op, &matcher, None, 0).unwrap();
1491 assert_eq!(result.text.unwrap(), "B\nB\na\n");
1492 assert_eq!(result.changes.len(), 2);
1493 }
1494
1495 #[test]
1496 fn test_count_first_in_file_in_multiline_mode() {
1497 let text = "a\na\n";
1498 let op = Op::Replace {
1499 find: "a".to_string(),
1500 replace: "B".to_string(),
1501 regex: false,
1502 case_insensitive: false,
1503 multiline: true,
1504 count: ReplaceCount::FirstInFile,
1505 };
1506 let matcher = Matcher::new(&op).unwrap();
1507 let result = apply(text, &op, &matcher, None, 0).unwrap();
1508 assert_eq!(result.text.unwrap(), "B\na\n");
1509 }
1510
1511 #[test]
1512 fn test_count_first_per_line_rejected_in_multiline_mode() {
1513 let op = Op::Replace {
1514 find: "a".to_string(),
1515 replace: "B".to_string(),
1516 regex: false,
1517 case_insensitive: false,
1518 multiline: true,
1519 count: ReplaceCount::FirstPerLine,
1520 };
1521 let matcher = Matcher::new(&op).unwrap();
1522 let err = apply("a\n", &op, &matcher, None, 0).unwrap_err();
1523 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1524 }
1525
1526 #[test]
1527 fn test_count_budget_exhausted_skips_remaining_lines() {
1528 let text = "a\na\na\na\n";
1531 let op = counted_replace_op("a", "B", ReplaceCount::Max(1));
1532 let matcher = Matcher::new(&op).unwrap();
1533 let result = apply(text, &op, &matcher, None, 0).unwrap();
1534 assert_eq!(result.text.unwrap(), "B\na\na\na\n");
1535 assert_eq!(result.changes.len(), 1);
1536 }
1537
1538 fn pattern_range(start: &str, end: &str) -> Option<RangeSpec> {
1541 Some(RangeSpec::Patterns(crate::operation::PatternRange {
1542 start_pattern: start.to_string(),
1543 end_pattern: end.to_string(),
1544 }))
1545 }
1546
1547 fn simple_replace(find: &str, replace: &str) -> Op {
1548 Op::Replace {
1549 find: find.to_string(),
1550 replace: replace.to_string(),
1551 regex: false,
1552 case_insensitive: false,
1553 multiline: false,
1554 count: ReplaceCount::All,
1555 }
1556 }
1557
1558 #[test]
1559 fn test_pattern_range_single_region_boundaries_inclusive() {
1560 let text = "x\nBEGIN x\nx\nEND x\nx\n";
1561 let op = simple_replace("x", "y");
1562 let matcher = Matcher::new(&op).unwrap();
1563 let result = apply(text, &op, &matcher, pattern_range("BEGIN", "END"), 0).unwrap();
1564 assert_eq!(result.text.unwrap(), "x\nBEGIN y\ny\nEND y\nx\n");
1566 }
1567
1568 #[test]
1569 fn test_pattern_range_multiple_regions() {
1570 let text = "x\nA\nx\nB\nx\nA\nx\nB\nx\n";
1571 let op = simple_replace("x", "y");
1572 let matcher = Matcher::new(&op).unwrap();
1573 let result = apply(text, &op, &matcher, pattern_range("A", "B"), 0).unwrap();
1574 assert_eq!(result.text.unwrap(), "x\nA\ny\nB\nx\nA\ny\nB\nx\n");
1577 }
1578
1579 #[test]
1580 fn test_pattern_range_unterminated_extends_to_eof() {
1581 let text = "x\nBEGIN\nx\nx\n";
1582 let op = simple_replace("x", "y");
1583 let matcher = Matcher::new(&op).unwrap();
1584 let result = apply(text, &op, &matcher, pattern_range("BEGIN", "NEVER"), 0).unwrap();
1585 assert_eq!(result.text.unwrap(), "x\nBEGIN\ny\ny\n");
1586 }
1587
1588 #[test]
1589 fn test_pattern_range_same_start_and_end_spans_to_next_match() {
1590 let text = "MARK\nx\nMARK\nx\n";
1593 let op = simple_replace("x", "y");
1594 let matcher = Matcher::new(&op).unwrap();
1595 let result = apply(text, &op, &matcher, pattern_range("MARK", "MARK"), 0).unwrap();
1596 assert_eq!(result.text.unwrap(), "MARK\ny\nMARK\nx\n");
1597 }
1598
1599 #[test]
1600 fn test_pattern_range_delete_can_remove_boundary_lines() {
1601 let text = "keep\nSTART\ngone\nSTOP\nkeep\n";
1602 let op = Op::Delete {
1603 find: ".*".to_string(),
1604 regex: true,
1605 case_insensitive: false,
1606 multiline: false,
1607 };
1608 let matcher = Matcher::new(&op).unwrap();
1609 let result = apply(text, &op, &matcher, pattern_range("START", "STOP"), 0).unwrap();
1610 assert_eq!(result.text.unwrap(), "keep\nkeep\n");
1611 }
1612
1613 #[test]
1614 fn test_pattern_range_start_regex_anchors() {
1615 let text = "prefix BEGIN\nx\nBEGIN\nx\nEND\n";
1616 let op = simple_replace("x", "y");
1617 let matcher = Matcher::new(&op).unwrap();
1618 let result = apply(text, &op, &matcher, pattern_range("^BEGIN$", "END"), 0).unwrap();
1620 assert_eq!(result.text.unwrap(), "prefix BEGIN\nx\nBEGIN\ny\nEND\n");
1621 }
1622
1623 #[test]
1624 fn test_pattern_range_invalid_regex_is_rejected() {
1625 let op = simple_replace("x", "y");
1626 let matcher = Matcher::new(&op).unwrap();
1627 let err = apply("x\n", &op, &matcher, pattern_range("(unclosed", "END"), 0).unwrap_err();
1628 assert_eq!(err.code, crate::error::ErrorCode::InvalidRegex);
1629 assert!(err.message.contains("start"));
1630 }
1631
1632 #[test]
1633 fn test_pattern_range_rejected_in_multiline_mode() {
1634 let op = Op::Replace {
1635 find: "x".to_string(),
1636 replace: "y".to_string(),
1637 regex: false,
1638 case_insensitive: false,
1639 multiline: true,
1640 count: ReplaceCount::All,
1641 };
1642 let matcher = Matcher::new(&op).unwrap();
1643 let err = apply("x\n", &op, &matcher, pattern_range("A", "B"), 0).unwrap_err();
1644 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1645 }
1646
1647 #[test]
1648 fn test_pattern_range_no_region_means_no_changes() {
1649 let op = simple_replace("x", "y");
1650 let matcher = Matcher::new(&op).unwrap();
1651 let result = apply("x\nx\n", &op, &matcher, pattern_range("NEVER", "END"), 0).unwrap();
1652 assert!(result.text.is_none());
1653 assert!(result.changes.is_empty());
1654 }
1655
1656 #[test]
1659 fn test_line_processor_matches_buffered_apply() {
1660 let text = "alpha x\nplain\nx x\n";
1663 let op = simple_replace("x", "y");
1664 let matcher = Matcher::new(&op).unwrap();
1665
1666 let buffered = apply(text, &op, &matcher, None, 0).unwrap().text.unwrap();
1667
1668 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1669 let mut streamed = String::new();
1670 for line in text.lines() {
1671 for out in processor.process_line(line).lines {
1672 streamed.push_str(&out);
1673 streamed.push('\n');
1674 }
1675 }
1676 assert_eq!(streamed, buffered);
1677 }
1678
1679 #[test]
1680 fn test_line_processor_rejects_multiline_ops() {
1681 let op = Op::Replace {
1682 find: "a\nb".to_string(),
1683 replace: "ab".to_string(),
1684 regex: false,
1685 case_insensitive: false,
1686 multiline: true,
1687 count: ReplaceCount::All,
1688 };
1689 let matcher = Matcher::new(&op).unwrap();
1690 let err = match LineProcessor::new(&op, &matcher, None) {
1691 Err(e) => e,
1692 Ok(_) => panic!("multiline op must be rejected by LineProcessor"),
1693 };
1694 assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1695 }
1696
1697 #[test]
1698 fn test_line_processor_budget_spans_calls() {
1699 let op = counted_replace_op("x", "y", ReplaceCount::Max(2));
1700 let matcher = Matcher::new(&op).unwrap();
1701 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1702 assert!(processor.process_line("x x").changed); let third = processor.process_line("x");
1704 assert!(!third.changed, "budget exhausted across calls");
1705 assert_eq!(third.lines, vec!["x".to_string()]);
1706 }
1707
1708 #[test]
1709 fn test_line_processor_pattern_range_state_spans_calls() {
1710 let op = simple_replace("x", "y");
1711 let matcher = Matcher::new(&op).unwrap();
1712 let range = pattern_range("BEGIN", "END");
1713 let mut processor = LineProcessor::new(&op, &matcher, range).unwrap();
1714 assert_eq!(processor.process_line("x").lines, vec!["x"]); processor.process_line("BEGIN");
1716 assert_eq!(processor.process_line("x").lines, vec!["y"]); processor.process_line("END");
1718 assert_eq!(processor.process_line("x").lines, vec!["x"]); }
1720
1721 #[test]
1722 fn test_line_processor_insert_and_delete_shapes() {
1723 let op = Op::InsertAfter {
1724 find: "mark".to_string(),
1725 content: "inserted".to_string(),
1726 regex: false,
1727 case_insensitive: false,
1728 };
1729 let matcher = Matcher::new(&op).unwrap();
1730 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1731 assert_eq!(
1732 processor.process_line("mark").lines,
1733 vec!["mark".to_string(), "inserted".to_string()]
1734 );
1735
1736 let op = Op::Delete {
1737 find: "gone".to_string(),
1738 regex: false,
1739 case_insensitive: false,
1740 multiline: false,
1741 };
1742 let matcher = Matcher::new(&op).unwrap();
1743 let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1744 let out = processor.process_line("gone");
1745 assert!(out.lines.is_empty());
1746 assert!(out.changed);
1747 }
1748
1749 fn line_path_range() -> Option<RangeSpec> {
1755 Some(RangeSpec::Lines(LineRange {
1756 start: 1,
1757 end: None,
1758 }))
1759 }
1760
1761 #[test]
1762 fn test_splice_matches_line_path_exactly() {
1763 for (text, find, replace) in [
1764 ("a x b\nx\nno match\nx x x\n", "x", "YY"),
1765 ("x at start\nend x", "x", ""),
1766 ("a\r\nx here\r\nx x\r\n", "x", "longer-replacement"),
1767 ("only\none\nmatch deep in file x\n", "x", "y"),
1768 ("x", "x", "swap"),
1769 ("multi x on x one x line\n", "x", "[]"),
1770 ] {
1771 let op = simple_replace(find, replace);
1772 let matcher = Matcher::new(&op).unwrap();
1773 let spliced = apply(text, &op, &matcher, None, 2).unwrap();
1774 let looped = apply(text, &op, &matcher, line_path_range(), 2).unwrap();
1775 assert_eq!(spliced.text, looped.text, "text for {text:?}");
1776 assert_eq!(spliced.changes, looped.changes, "changes for {text:?}");
1777 }
1778 }
1779
1780 #[test]
1781 fn test_splice_case_insensitive_literal() {
1782 let text = "Foo bar\nFOO\nplain\n";
1783 let op = Op::Replace {
1784 find: "foo".to_string(),
1785 replace: "baz".to_string(),
1786 regex: false,
1787 case_insensitive: true,
1788 multiline: false,
1789 count: ReplaceCount::All,
1790 };
1791 let matcher = Matcher::new(&op).unwrap();
1792 let spliced = apply(text, &op, &matcher, None, 1).unwrap();
1793 let looped = apply(text, &op, &matcher, line_path_range(), 1).unwrap();
1794 assert_eq!(spliced.text, looped.text);
1795 assert_eq!(spliced.changes, looped.changes);
1796 assert_eq!(spliced.text.unwrap(), "baz bar\nbaz\nplain\n");
1797 }
1798
1799 #[test]
1800 fn test_splice_count_parity() {
1801 let text = "x x\nx x\nx\n";
1802 for count in [ReplaceCount::FirstInFile, ReplaceCount::Max(3)] {
1803 let op = counted_replace_op("x", "y", count);
1804 let matcher = Matcher::new(&op).unwrap();
1805 let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1806 let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1807 assert_eq!(spliced.text, looped.text, "{count:?}");
1808 assert_eq!(spliced.changes, looped.changes, "{count:?}");
1809 }
1810 }
1811
1812 #[test]
1813 fn test_splice_mixed_endings_falls_back_and_normalizes() {
1814 let text = "x\r\nx\r\nx\n";
1817 let op = simple_replace("x", "y");
1818 let matcher = Matcher::new(&op).unwrap();
1819 let result = apply(text, &op, &matcher, None, 0).unwrap();
1820 assert_eq!(result.text.unwrap(), "y\r\ny\r\ny\r\n");
1821 }
1822
1823 #[test]
1824 fn test_splice_replacement_with_newline() {
1825 let text = "a SPLIT b\nplain\n";
1826 let op = simple_replace("SPLIT", "one\ntwo");
1827 let matcher = Matcher::new(&op).unwrap();
1828 let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1829 let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1830 assert_eq!(spliced.text, looped.text);
1831 assert_eq!(spliced.text.unwrap(), "a one\ntwo b\nplain\n");
1832 }
1833
1834 #[test]
1835 fn test_context_capped_after_threshold_on_both_paths() {
1836 let text = "x\n".repeat(1100);
1839 let op = simple_replace("x", "y");
1840 let matcher = Matcher::new(&op).unwrap();
1841 for range in [None, line_path_range()] {
1842 let result = apply(&text, &op, &matcher, range, 1).unwrap();
1843 assert_eq!(result.changes.len(), 1100);
1844 assert!(result.changes[0].context.is_some());
1845 assert!(result.changes[999].context.is_some());
1846 assert!(result.changes[1000].context.is_none());
1847 assert!(result.changes[1099].context.is_none());
1848 }
1849 }
1850
1851 #[test]
1852 fn test_uses_crlf_detection() {
1853 assert!(uses_crlf("a\r\nb\r\n"));
1854 assert!(uses_crlf("a\r\n"));
1855 assert!(!uses_crlf("a\nb\n"));
1856 assert!(!uses_crlf("no newline at all"));
1857 assert!(!uses_crlf(""));
1858 }
1859
1860 #[test]
1865 fn test_empty_input_text() {
1866 let text = "";
1867 let op = Op::Replace {
1868 count: Default::default(),
1869 multiline: false,
1870 find: "anything".to_string(),
1871 replace: "something".to_string(),
1872 regex: false,
1873 case_insensitive: false,
1874 };
1875 let matcher = Matcher::new(&op).unwrap();
1876 let result = apply(text, &op, &matcher, None, 0).unwrap();
1877 assert!(result.text.is_none());
1878 assert!(result.changes.is_empty());
1879 }
1880
1881 #[test]
1882 fn test_single_line_no_trailing_newline() {
1883 let text = "hello world";
1884 let op = Op::Replace {
1885 count: Default::default(),
1886 multiline: false,
1887 find: "hello".to_string(),
1888 replace: "hi".to_string(),
1889 regex: false,
1890 case_insensitive: false,
1891 };
1892 let matcher = Matcher::new(&op).unwrap();
1893 let result = apply(text, &op, &matcher, None, 0).unwrap();
1894 let output = result.text.unwrap();
1895 assert_eq!(output, "hi world");
1896 assert!(!output.ends_with('\n'));
1898 }
1899
1900 #[test]
1901 fn test_whitespace_only_lines() {
1902 let text = " \n\t\n \t \n";
1903 let op = Op::Replace {
1904 count: Default::default(),
1905 multiline: false,
1906 find: "\t".to_string(),
1907 replace: "TAB".to_string(),
1908 regex: false,
1909 case_insensitive: false,
1910 };
1911 let matcher = Matcher::new(&op).unwrap();
1912 let result = apply(text, &op, &matcher, None, 0).unwrap();
1913 let output = result.text.unwrap();
1914 assert!(output.contains("TAB"));
1915 assert_eq!(result.changes.len(), 2); }
1917
1918 #[test]
1919 fn test_very_long_line() {
1920 let long_word = "x".repeat(100_000);
1921 let text = format!("before\n{long_word}\nafter\n");
1922 let op = Op::Replace {
1923 count: Default::default(),
1924 multiline: false,
1925 find: "x".to_string(),
1926 replace: "y".to_string(),
1927 regex: false,
1928 case_insensitive: false,
1929 };
1930 let matcher = Matcher::new(&op).unwrap();
1931 let result = apply(&text, &op, &matcher, None, 0).unwrap();
1932 let output = result.text.unwrap();
1933 let expected_long = "y".repeat(100_000);
1934 assert!(output.contains(&expected_long));
1935 }
1936
1937 #[test]
1938 fn test_unicode_emoji() {
1939 let text = "hello world\n";
1940 let op = Op::Replace {
1941 count: Default::default(),
1942 multiline: false,
1943 find: "world".to_string(),
1944 replace: "\u{1F30D}".to_string(), regex: false,
1946 case_insensitive: false,
1947 };
1948 let matcher = Matcher::new(&op).unwrap();
1949 let result = apply(text, &op, &matcher, None, 0).unwrap();
1950 assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
1951 }
1952
1953 #[test]
1954 fn test_unicode_cjk() {
1955 let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; let op = Op::Replace {
1957 count: Default::default(),
1958 multiline: false,
1959 find: "\u{4E16}\u{754C}".to_string(), replace: "\u{5730}\u{7403}".to_string(), regex: false,
1962 case_insensitive: false,
1963 };
1964 let matcher = Matcher::new(&op).unwrap();
1965 let result = apply(text, &op, &matcher, None, 0).unwrap();
1966 assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
1967 }
1968
1969 #[test]
1970 fn test_unicode_combining_characters() {
1971 let text = "caf\u{0065}\u{0301}\n";
1973 let op = Op::Replace {
1974 count: Default::default(),
1975 multiline: false,
1976 find: "caf\u{0065}\u{0301}".to_string(),
1977 replace: "coffee".to_string(),
1978 regex: false,
1979 case_insensitive: false,
1980 };
1981 let matcher = Matcher::new(&op).unwrap();
1982 let result = apply(text, &op, &matcher, None, 0).unwrap();
1983 assert_eq!(result.text.unwrap(), "coffee\n");
1984 }
1985
1986 #[test]
1987 fn test_regex_special_chars_in_literal_mode() {
1988 let text = "price is $10.00 (USD)\n";
1990 let op = Op::Replace {
1991 count: Default::default(),
1992 multiline: false,
1993 find: "$10.00".to_string(),
1994 replace: "$20.00".to_string(),
1995 regex: false,
1996 case_insensitive: false,
1997 };
1998 let matcher = Matcher::new(&op).unwrap();
1999 let result = apply(text, &op, &matcher, None, 0).unwrap();
2000 assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
2001 }
2002
2003 #[test]
2004 fn test_overlapping_matches_in_single_line() {
2005 let text = "aaa\n";
2007 let op = Op::Replace {
2008 count: Default::default(),
2009 multiline: false,
2010 find: "aa".to_string(),
2011 replace: "b".to_string(),
2012 regex: false,
2013 case_insensitive: false,
2014 };
2015 let matcher = Matcher::new(&op).unwrap();
2016 let result = apply(text, &op, &matcher, None, 0).unwrap();
2017 assert_eq!(result.text.unwrap(), "ba\n");
2019 }
2020
2021 #[test]
2022 fn test_replace_line_count_preserved() {
2023 let text = "line1\nline2\nline3\nline4\nline5\n";
2024 let input_line_count = text.lines().count();
2025 let op = Op::Replace {
2026 count: Default::default(),
2027 multiline: false,
2028 find: "line".to_string(),
2029 replace: "row".to_string(),
2030 regex: false,
2031 case_insensitive: false,
2032 };
2033 let matcher = Matcher::new(&op).unwrap();
2034 let result = apply(text, &op, &matcher, None, 0).unwrap();
2035 let output = result.text.unwrap();
2036 let output_line_count = output.lines().count();
2037 assert_eq!(input_line_count, output_line_count);
2038 }
2039
2040 #[test]
2041 fn test_replace_preserves_empty_result_on_non_match() {
2042 let text = "alpha\nbeta\ngamma\n";
2044 let op = Op::Replace {
2045 count: Default::default(),
2046 multiline: false,
2047 find: "zzzzzz".to_string(),
2048 replace: "y".to_string(),
2049 regex: false,
2050 case_insensitive: false,
2051 };
2052 let matcher = Matcher::new(&op).unwrap();
2053 let result = apply(text, &op, &matcher, None, 0).unwrap();
2054 assert!(result.text.is_none());
2055 assert!(result.undo.is_none());
2056 }
2057
2058 #[test]
2059 fn test_undo_entry_stores_original() {
2060 let text = "hello\nworld\n";
2061 let op = Op::Replace {
2062 count: Default::default(),
2063 multiline: false,
2064 find: "hello".to_string(),
2065 replace: "hi".to_string(),
2066 regex: false,
2067 case_insensitive: false,
2068 };
2069 let matcher = Matcher::new(&op).unwrap();
2070 let result = apply(text, &op, &matcher, None, 0).unwrap();
2071 let undo = result.undo.unwrap();
2072 assert_eq!(undo.original_text, text);
2073 }
2074
2075 #[test]
2076 fn test_determinism_same_input_same_output() {
2077 let text = "foo bar baz\nhello world\nfoo again\n";
2078 let op = Op::Replace {
2079 count: Default::default(),
2080 multiline: false,
2081 find: "foo".to_string(),
2082 replace: "qux".to_string(),
2083 regex: false,
2084 case_insensitive: false,
2085 };
2086 let matcher = Matcher::new(&op).unwrap();
2087 let r1 = apply(text, &op, &matcher, None, 0).unwrap();
2088 let r2 = apply(text, &op, &matcher, None, 0).unwrap();
2089 assert_eq!(r1.text, r2.text);
2090 assert_eq!(r1.changes.len(), r2.changes.len());
2091 for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
2092 assert_eq!(c1, c2);
2093 }
2094 }
2095
2096 #[test]
2101 fn test_transform_upper() {
2102 let text = "hello world\nfoo bar\n";
2103 let op = Op::Transform {
2104 find: "hello".to_string(),
2105 mode: TransformMode::Upper,
2106 regex: false,
2107 case_insensitive: false,
2108 };
2109 let matcher = Matcher::new(&op).unwrap();
2110 let result = apply(text, &op, &matcher, None, 0).unwrap();
2111 assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
2112 assert_eq!(result.changes.len(), 1);
2113 assert_eq!(result.changes[0].line, 1);
2114 }
2115
2116 #[test]
2117 fn test_transform_lower() {
2118 let text = "HELLO WORLD\nFOO BAR\n";
2119 let op = Op::Transform {
2120 find: "HELLO".to_string(),
2121 mode: TransformMode::Lower,
2122 regex: false,
2123 case_insensitive: false,
2124 };
2125 let matcher = Matcher::new(&op).unwrap();
2126 let result = apply(text, &op, &matcher, None, 0).unwrap();
2127 assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
2128 assert_eq!(result.changes.len(), 1);
2129 }
2130
2131 #[test]
2132 fn test_transform_noop_when_already_target_case() {
2133 let text = "hello world\nfoo bar\n";
2135 let op = Op::Transform {
2136 find: "hello".to_string(),
2137 mode: TransformMode::Lower,
2138 regex: false,
2139 case_insensitive: false,
2140 };
2141 let matcher = Matcher::new(&op).unwrap();
2142 let result = apply(text, &op, &matcher, None, 0).unwrap();
2143 assert!(result.text.is_none(), "No text modification expected");
2144 assert!(result.changes.is_empty(), "No changes expected");
2145 }
2146
2147 #[test]
2148 fn test_transform_title() {
2149 let text = "hello world\nfoo bar\n";
2150 let op = Op::Transform {
2151 find: "hello world".to_string(),
2152 mode: TransformMode::Title,
2153 regex: false,
2154 case_insensitive: false,
2155 };
2156 let matcher = Matcher::new(&op).unwrap();
2157 let result = apply(text, &op, &matcher, None, 0).unwrap();
2158 assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
2159 assert_eq!(result.changes.len(), 1);
2160 }
2161
2162 #[test]
2163 fn test_transform_snake_case() {
2164 let text = "let myVariable = 1;\nother line\n";
2165 let op = Op::Transform {
2166 find: "myVariable".to_string(),
2167 mode: TransformMode::SnakeCase,
2168 regex: false,
2169 case_insensitive: false,
2170 };
2171 let matcher = Matcher::new(&op).unwrap();
2172 let result = apply(text, &op, &matcher, None, 0).unwrap();
2173 assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
2174 assert_eq!(result.changes.len(), 1);
2175 }
2176
2177 #[test]
2178 fn test_transform_camel_case() {
2179 let text = "let my_variable = 1;\nother line\n";
2180 let op = Op::Transform {
2181 find: "my_variable".to_string(),
2182 mode: TransformMode::CamelCase,
2183 regex: false,
2184 case_insensitive: false,
2185 };
2186 let matcher = Matcher::new(&op).unwrap();
2187 let result = apply(text, &op, &matcher, None, 0).unwrap();
2188 assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
2189 assert_eq!(result.changes.len(), 1);
2190 }
2191
2192 #[test]
2193 fn test_transform_upper_multiple_matches_on_line() {
2194 let text = "hello and hello again\n";
2195 let op = Op::Transform {
2196 find: "hello".to_string(),
2197 mode: TransformMode::Upper,
2198 regex: false,
2199 case_insensitive: false,
2200 };
2201 let matcher = Matcher::new(&op).unwrap();
2202 let result = apply(text, &op, &matcher, None, 0).unwrap();
2203 assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
2204 }
2205
2206 #[test]
2207 fn test_transform_no_match() {
2208 let text = "hello world\n";
2209 let op = Op::Transform {
2210 find: "zzz".to_string(),
2211 mode: TransformMode::Upper,
2212 regex: false,
2213 case_insensitive: false,
2214 };
2215 let matcher = Matcher::new(&op).unwrap();
2216 let result = apply(text, &op, &matcher, None, 0).unwrap();
2217 assert!(result.text.is_none());
2218 assert!(result.changes.is_empty());
2219 }
2220
2221 #[test]
2222 fn test_transform_empty_text() {
2223 let text = "";
2224 let op = Op::Transform {
2225 find: "anything".to_string(),
2226 mode: TransformMode::Upper,
2227 regex: false,
2228 case_insensitive: false,
2229 };
2230 let matcher = Matcher::new(&op).unwrap();
2231 let result = apply(text, &op, &matcher, None, 0).unwrap();
2232 assert!(result.text.is_none());
2233 assert!(result.changes.is_empty());
2234 }
2235
2236 #[test]
2237 fn test_transform_with_regex() {
2238 let text = "let fooBar = 1;\nlet bazQux = 2;\n";
2239 let op = Op::Transform {
2240 find: r"[a-z]+[A-Z]\w*".to_string(),
2241 mode: TransformMode::SnakeCase,
2242 regex: true,
2243 case_insensitive: false,
2244 };
2245 let matcher = Matcher::new(&op).unwrap();
2246 let result = apply(text, &op, &matcher, None, 0).unwrap();
2247 let output = result.text.unwrap();
2248 assert!(output.contains("foo_bar"));
2249 assert!(output.contains("baz_qux"));
2250 assert_eq!(result.changes.len(), 2);
2251 }
2252
2253 #[test]
2254 fn test_transform_case_insensitive() {
2255 let text = "Hello HELLO hello\n";
2256 let op = Op::Transform {
2257 find: "hello".to_string(),
2258 mode: TransformMode::Upper,
2259 regex: false,
2260 case_insensitive: true,
2261 };
2262 let matcher = Matcher::new(&op).unwrap();
2263 let result = apply(text, &op, &matcher, None, 0).unwrap();
2264 assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
2265 }
2266
2267 #[test]
2268 fn test_transform_crlf_preserved() {
2269 let text = "hello world\r\nfoo bar\r\n";
2270 let op = Op::Transform {
2271 find: "hello".to_string(),
2272 mode: TransformMode::Upper,
2273 regex: false,
2274 case_insensitive: false,
2275 };
2276 let matcher = Matcher::new(&op).unwrap();
2277 let result = apply(text, &op, &matcher, None, 0).unwrap();
2278 assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
2279 }
2280
2281 #[test]
2282 fn test_transform_with_line_range() {
2283 let text = "hello\nhello\nhello\nhello\n";
2284 let op = Op::Transform {
2285 find: "hello".to_string(),
2286 mode: TransformMode::Upper,
2287 regex: false,
2288 case_insensitive: false,
2289 };
2290 let range = Some(LineRange {
2291 start: 2,
2292 end: Some(3),
2293 });
2294 let matcher = Matcher::new(&op).unwrap();
2295 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2296 assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
2297 assert_eq!(result.changes.len(), 2);
2298 }
2299
2300 #[test]
2301 fn test_transform_title_with_underscores() {
2302 let text = "my_func_name\n";
2303 let op = Op::Transform {
2304 find: "my_func_name".to_string(),
2305 mode: TransformMode::Title,
2306 regex: false,
2307 case_insensitive: false,
2308 };
2309 let matcher = Matcher::new(&op).unwrap();
2310 let result = apply(text, &op, &matcher, None, 0).unwrap();
2311 assert_eq!(result.text.unwrap(), "My_Func_Name\n");
2313 }
2314
2315 #[test]
2316 fn test_transform_snake_case_from_multi_word() {
2317 let text = "my-kebab-case\n";
2318 let op = Op::Transform {
2319 find: "my-kebab-case".to_string(),
2320 mode: TransformMode::SnakeCase,
2321 regex: false,
2322 case_insensitive: false,
2323 };
2324 let matcher = Matcher::new(&op).unwrap();
2325 let result = apply(text, &op, &matcher, None, 0).unwrap();
2326 assert_eq!(result.text.unwrap(), "my_kebab_case\n");
2327 }
2328
2329 #[test]
2330 fn test_transform_camel_case_from_snake() {
2331 let text = "my_var_name\n";
2332 let op = Op::Transform {
2333 find: "my_var_name".to_string(),
2334 mode: TransformMode::CamelCase,
2335 regex: false,
2336 case_insensitive: false,
2337 };
2338 let matcher = Matcher::new(&op).unwrap();
2339 let result = apply(text, &op, &matcher, None, 0).unwrap();
2340 assert_eq!(result.text.unwrap(), "myVarName\n");
2341 }
2342
2343 #[test]
2344 fn test_transform_camel_case_from_kebab() {
2345 let text = "my-var-name\n";
2346 let op = Op::Transform {
2347 find: "my-var-name".to_string(),
2348 mode: TransformMode::CamelCase,
2349 regex: false,
2350 case_insensitive: false,
2351 };
2352 let matcher = Matcher::new(&op).unwrap();
2353 let result = apply(text, &op, &matcher, None, 0).unwrap();
2354 assert_eq!(result.text.unwrap(), "myVarName\n");
2355 }
2356
2357 #[test]
2362 fn test_surround_basic() {
2363 let text = "hello world\nfoo bar\n";
2364 let op = Op::Surround {
2365 find: "hello".to_string(),
2366 prefix: "<<".to_string(),
2367 suffix: ">>".to_string(),
2368 regex: false,
2369 case_insensitive: false,
2370 };
2371 let matcher = Matcher::new(&op).unwrap();
2372 let result = apply(text, &op, &matcher, None, 0).unwrap();
2373 assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
2374 assert_eq!(result.changes.len(), 1);
2375 assert_eq!(result.changes[0].line, 1);
2376 }
2377
2378 #[test]
2379 fn test_surround_multiple_lines() {
2380 let text = "foo line 1\nbar line 2\nfoo line 3\n";
2381 let op = Op::Surround {
2382 find: "foo".to_string(),
2383 prefix: "[".to_string(),
2384 suffix: "]".to_string(),
2385 regex: false,
2386 case_insensitive: false,
2387 };
2388 let matcher = Matcher::new(&op).unwrap();
2389 let result = apply(text, &op, &matcher, None, 0).unwrap();
2390 assert_eq!(
2391 result.text.unwrap(),
2392 "[foo line 1]\nbar line 2\n[foo line 3]\n"
2393 );
2394 assert_eq!(result.changes.len(), 2);
2395 }
2396
2397 #[test]
2398 fn test_surround_no_match() {
2399 let text = "hello world\n";
2400 let op = Op::Surround {
2401 find: "zzz".to_string(),
2402 prefix: "<".to_string(),
2403 suffix: ">".to_string(),
2404 regex: false,
2405 case_insensitive: false,
2406 };
2407 let matcher = Matcher::new(&op).unwrap();
2408 let result = apply(text, &op, &matcher, None, 0).unwrap();
2409 assert!(result.text.is_none());
2410 assert!(result.changes.is_empty());
2411 }
2412
2413 #[test]
2414 fn test_surround_empty_text() {
2415 let text = "";
2416 let op = Op::Surround {
2417 find: "anything".to_string(),
2418 prefix: "<".to_string(),
2419 suffix: ">".to_string(),
2420 regex: false,
2421 case_insensitive: false,
2422 };
2423 let matcher = Matcher::new(&op).unwrap();
2424 let result = apply(text, &op, &matcher, None, 0).unwrap();
2425 assert!(result.text.is_none());
2426 assert!(result.changes.is_empty());
2427 }
2428
2429 #[test]
2430 fn test_surround_with_regex() {
2431 let text = "fn main() {\n let x = 1;\n}\n";
2432 let op = Op::Surround {
2433 find: r"fn\s+\w+".to_string(),
2434 prefix: "/* ".to_string(),
2435 suffix: " */".to_string(),
2436 regex: true,
2437 case_insensitive: false,
2438 };
2439 let matcher = Matcher::new(&op).unwrap();
2440 let result = apply(text, &op, &matcher, None, 0).unwrap();
2441 assert_eq!(
2442 result.text.unwrap(),
2443 "/* fn main() { */\n let x = 1;\n}\n"
2444 );
2445 }
2446
2447 #[test]
2448 fn test_surround_case_insensitive() {
2449 let text = "Hello world\nhello world\nHELLO world\n";
2450 let op = Op::Surround {
2451 find: "hello".to_string(),
2452 prefix: "(".to_string(),
2453 suffix: ")".to_string(),
2454 regex: false,
2455 case_insensitive: true,
2456 };
2457 let matcher = Matcher::new(&op).unwrap();
2458 let result = apply(text, &op, &matcher, None, 0).unwrap();
2459 let output = result.text.unwrap();
2460 assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
2461 assert_eq!(result.changes.len(), 3);
2462 }
2463
2464 #[test]
2465 fn test_surround_crlf_preserved() {
2466 let text = "hello world\r\nfoo bar\r\n";
2467 let op = Op::Surround {
2468 find: "hello".to_string(),
2469 prefix: "[".to_string(),
2470 suffix: "]".to_string(),
2471 regex: false,
2472 case_insensitive: false,
2473 };
2474 let matcher = Matcher::new(&op).unwrap();
2475 let result = apply(text, &op, &matcher, None, 0).unwrap();
2476 assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
2477 }
2478
2479 #[test]
2480 fn test_surround_with_line_range() {
2481 let text = "foo\nfoo\nfoo\nfoo\n";
2482 let op = Op::Surround {
2483 find: "foo".to_string(),
2484 prefix: "<".to_string(),
2485 suffix: ">".to_string(),
2486 regex: false,
2487 case_insensitive: false,
2488 };
2489 let range = Some(LineRange {
2490 start: 2,
2491 end: Some(3),
2492 });
2493 let matcher = Matcher::new(&op).unwrap();
2494 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2495 assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
2496 assert_eq!(result.changes.len(), 2);
2497 }
2498
2499 #[test]
2500 fn test_surround_with_empty_prefix_and_suffix() {
2501 let text = "hello world\n";
2502 let op = Op::Surround {
2503 find: "hello".to_string(),
2504 prefix: String::new(),
2505 suffix: String::new(),
2506 regex: false,
2507 case_insensitive: false,
2508 };
2509 let matcher = Matcher::new(&op).unwrap();
2510 let result = apply(text, &op, &matcher, None, 0).unwrap();
2511 assert!(result.text.is_none());
2513 assert!(result.changes.is_empty());
2514 }
2515
2516 #[test]
2521 fn test_indent_basic() {
2522 let text = "hello\nworld\n";
2523 let op = Op::Indent {
2524 find: "hello".to_string(),
2525 amount: 4,
2526 use_tabs: false,
2527 regex: false,
2528 case_insensitive: false,
2529 };
2530 let matcher = Matcher::new(&op).unwrap();
2531 let result = apply(text, &op, &matcher, None, 0).unwrap();
2532 assert_eq!(result.text.unwrap(), " hello\nworld\n");
2533 assert_eq!(result.changes.len(), 1);
2534 }
2535
2536 #[test]
2537 fn test_indent_multiple_lines() {
2538 let text = "foo line 1\nbar line 2\nfoo line 3\n";
2539 let op = Op::Indent {
2540 find: "foo".to_string(),
2541 amount: 2,
2542 use_tabs: false,
2543 regex: false,
2544 case_insensitive: false,
2545 };
2546 let matcher = Matcher::new(&op).unwrap();
2547 let result = apply(text, &op, &matcher, None, 0).unwrap();
2548 assert_eq!(
2549 result.text.unwrap(),
2550 " foo line 1\nbar line 2\n foo line 3\n"
2551 );
2552 assert_eq!(result.changes.len(), 2);
2553 }
2554
2555 #[test]
2556 fn test_indent_with_tabs() {
2557 let text = "hello\nworld\n";
2558 let op = Op::Indent {
2559 find: "hello".to_string(),
2560 amount: 2,
2561 use_tabs: true,
2562 regex: false,
2563 case_insensitive: false,
2564 };
2565 let matcher = Matcher::new(&op).unwrap();
2566 let result = apply(text, &op, &matcher, None, 0).unwrap();
2567 assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
2568 }
2569
2570 #[test]
2571 fn test_indent_no_match() {
2572 let text = "hello world\n";
2573 let op = Op::Indent {
2574 find: "zzz".to_string(),
2575 amount: 4,
2576 use_tabs: false,
2577 regex: false,
2578 case_insensitive: false,
2579 };
2580 let matcher = Matcher::new(&op).unwrap();
2581 let result = apply(text, &op, &matcher, None, 0).unwrap();
2582 assert!(result.text.is_none());
2583 assert!(result.changes.is_empty());
2584 }
2585
2586 #[test]
2587 fn test_indent_empty_text() {
2588 let text = "";
2589 let op = Op::Indent {
2590 find: "anything".to_string(),
2591 amount: 4,
2592 use_tabs: false,
2593 regex: false,
2594 case_insensitive: false,
2595 };
2596 let matcher = Matcher::new(&op).unwrap();
2597 let result = apply(text, &op, &matcher, None, 0).unwrap();
2598 assert!(result.text.is_none());
2599 assert!(result.changes.is_empty());
2600 }
2601
2602 #[test]
2603 fn test_indent_zero_amount() {
2604 let text = "hello\n";
2605 let op = Op::Indent {
2606 find: "hello".to_string(),
2607 amount: 0,
2608 use_tabs: false,
2609 regex: false,
2610 case_insensitive: false,
2611 };
2612 let matcher = Matcher::new(&op).unwrap();
2613 let result = apply(text, &op, &matcher, None, 0).unwrap();
2614 assert!(result.text.is_none());
2616 assert!(result.changes.is_empty());
2617 }
2618
2619 #[test]
2620 fn test_indent_with_regex() {
2621 let text = "fn main() {\nlet x = 1;\n}\n";
2622 let op = Op::Indent {
2623 find: r"let\s+".to_string(),
2624 amount: 4,
2625 use_tabs: false,
2626 regex: true,
2627 case_insensitive: false,
2628 };
2629 let matcher = Matcher::new(&op).unwrap();
2630 let result = apply(text, &op, &matcher, None, 0).unwrap();
2631 assert_eq!(result.text.unwrap(), "fn main() {\n let x = 1;\n}\n");
2632 assert_eq!(result.changes.len(), 1);
2633 }
2634
2635 #[test]
2636 fn test_indent_case_insensitive() {
2637 let text = "Hello\nhello\nHELLO\n";
2638 let op = Op::Indent {
2639 find: "hello".to_string(),
2640 amount: 2,
2641 use_tabs: false,
2642 regex: false,
2643 case_insensitive: true,
2644 };
2645 let matcher = Matcher::new(&op).unwrap();
2646 let result = apply(text, &op, &matcher, None, 0).unwrap();
2647 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
2648 assert_eq!(result.changes.len(), 3);
2649 }
2650
2651 #[test]
2652 fn test_indent_crlf_preserved() {
2653 let text = "hello\r\nworld\r\n";
2654 let op = Op::Indent {
2655 find: "hello".to_string(),
2656 amount: 4,
2657 use_tabs: false,
2658 regex: false,
2659 case_insensitive: false,
2660 };
2661 let matcher = Matcher::new(&op).unwrap();
2662 let result = apply(text, &op, &matcher, None, 0).unwrap();
2663 assert_eq!(result.text.unwrap(), " hello\r\nworld\r\n");
2664 }
2665
2666 #[test]
2667 fn test_indent_with_line_range() {
2668 let text = "foo\nfoo\nfoo\nfoo\n";
2669 let op = Op::Indent {
2670 find: "foo".to_string(),
2671 amount: 4,
2672 use_tabs: false,
2673 regex: false,
2674 case_insensitive: false,
2675 };
2676 let range = Some(LineRange {
2677 start: 2,
2678 end: Some(3),
2679 });
2680 let matcher = Matcher::new(&op).unwrap();
2681 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2682 assert_eq!(result.text.unwrap(), "foo\n foo\n foo\nfoo\n");
2683 assert_eq!(result.changes.len(), 2);
2684 }
2685
2686 #[test]
2691 fn test_dedent_basic() {
2692 let text = " hello\nworld\n";
2693 let op = Op::Dedent {
2694 find: "hello".to_string(),
2695 amount: 4,
2696 use_tabs: false,
2697 regex: false,
2698 case_insensitive: false,
2699 };
2700 let matcher = Matcher::new(&op).unwrap();
2701 let result = apply(text, &op, &matcher, None, 0).unwrap();
2702 assert_eq!(result.text.unwrap(), "hello\nworld\n");
2703 assert_eq!(result.changes.len(), 1);
2704 }
2705
2706 #[test]
2707 fn test_dedent_partial() {
2708 let text = " hello\n";
2710 let op = Op::Dedent {
2711 find: "hello".to_string(),
2712 amount: 4,
2713 use_tabs: false,
2714 regex: false,
2715 case_insensitive: false,
2716 };
2717 let matcher = Matcher::new(&op).unwrap();
2718 let result = apply(text, &op, &matcher, None, 0).unwrap();
2719 assert_eq!(result.text.unwrap(), "hello\n");
2720 }
2721
2722 #[test]
2723 fn test_dedent_no_leading_spaces() {
2724 let text = "hello\n";
2726 let op = Op::Dedent {
2727 find: "hello".to_string(),
2728 amount: 4,
2729 use_tabs: false,
2730 regex: false,
2731 case_insensitive: false,
2732 };
2733 let matcher = Matcher::new(&op).unwrap();
2734 let result = apply(text, &op, &matcher, None, 0).unwrap();
2735 assert!(result.text.is_none());
2737 assert!(result.changes.is_empty());
2738 }
2739
2740 #[test]
2741 fn test_dedent_multiple_lines() {
2742 let text = " foo line 1\n bar line 2\n foo line 3\n";
2743 let op = Op::Dedent {
2744 find: "foo".to_string(),
2745 amount: 4,
2746 use_tabs: false,
2747 regex: false,
2748 case_insensitive: false,
2749 };
2750 let matcher = Matcher::new(&op).unwrap();
2751 let result = apply(text, &op, &matcher, None, 0).unwrap();
2752 assert_eq!(
2753 result.text.unwrap(),
2754 "foo line 1\n bar line 2\nfoo line 3\n"
2755 );
2756 assert_eq!(result.changes.len(), 2);
2757 }
2758
2759 #[test]
2760 fn test_dedent_no_match() {
2761 let text = " hello world\n";
2762 let op = Op::Dedent {
2763 find: "zzz".to_string(),
2764 amount: 4,
2765 use_tabs: false,
2766 regex: false,
2767 case_insensitive: false,
2768 };
2769 let matcher = Matcher::new(&op).unwrap();
2770 let result = apply(text, &op, &matcher, None, 0).unwrap();
2771 assert!(result.text.is_none());
2772 assert!(result.changes.is_empty());
2773 }
2774
2775 #[test]
2776 fn test_dedent_empty_text() {
2777 let text = "";
2778 let op = Op::Dedent {
2779 find: "anything".to_string(),
2780 amount: 4,
2781 use_tabs: false,
2782 regex: false,
2783 case_insensitive: false,
2784 };
2785 let matcher = Matcher::new(&op).unwrap();
2786 let result = apply(text, &op, &matcher, None, 0).unwrap();
2787 assert!(result.text.is_none());
2788 assert!(result.changes.is_empty());
2789 }
2790
2791 #[test]
2792 fn test_dedent_with_regex() {
2793 let text = " let x = 1;\n fn main() {\n";
2794 let op = Op::Dedent {
2795 find: r"let\s+".to_string(),
2796 amount: 4,
2797 use_tabs: false,
2798 regex: true,
2799 case_insensitive: false,
2800 };
2801 let matcher = Matcher::new(&op).unwrap();
2802 let result = apply(text, &op, &matcher, None, 0).unwrap();
2803 assert_eq!(result.text.unwrap(), "let x = 1;\n fn main() {\n");
2804 assert_eq!(result.changes.len(), 1);
2805 }
2806
2807 #[test]
2808 fn test_dedent_case_insensitive() {
2809 let text = " Hello\n hello\n HELLO\n";
2810 let op = Op::Dedent {
2811 find: "hello".to_string(),
2812 amount: 2,
2813 use_tabs: false,
2814 regex: false,
2815 case_insensitive: true,
2816 };
2817 let matcher = Matcher::new(&op).unwrap();
2818 let result = apply(text, &op, &matcher, None, 0).unwrap();
2819 assert_eq!(result.text.unwrap(), " Hello\n hello\n HELLO\n");
2820 assert_eq!(result.changes.len(), 3);
2821 }
2822
2823 #[test]
2824 fn test_dedent_crlf_preserved() {
2825 let text = " hello\r\nworld\r\n";
2826 let op = Op::Dedent {
2827 find: "hello".to_string(),
2828 amount: 4,
2829 use_tabs: false,
2830 regex: false,
2831 case_insensitive: false,
2832 };
2833 let matcher = Matcher::new(&op).unwrap();
2834 let result = apply(text, &op, &matcher, None, 0).unwrap();
2835 assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
2836 }
2837
2838 #[test]
2839 fn test_dedent_with_line_range() {
2840 let text = " foo\n foo\n foo\n foo\n";
2841 let op = Op::Dedent {
2842 find: "foo".to_string(),
2843 amount: 4,
2844 use_tabs: false,
2845 regex: false,
2846 case_insensitive: false,
2847 };
2848 let range = Some(LineRange {
2849 start: 2,
2850 end: Some(3),
2851 });
2852 let matcher = Matcher::new(&op).unwrap();
2853 let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2854 assert_eq!(result.text.unwrap(), " foo\nfoo\nfoo\n foo\n");
2855 assert_eq!(result.changes.len(), 2);
2856 }
2857
2858 #[test]
2859 fn test_dedent_only_removes_spaces_not_tabs() {
2860 let text = "\t\thello\n";
2862 let op = Op::Dedent {
2863 find: "hello".to_string(),
2864 amount: 4,
2865 use_tabs: false,
2866 regex: false,
2867 case_insensitive: false,
2868 };
2869 let matcher = Matcher::new(&op).unwrap();
2870 let result = apply(text, &op, &matcher, None, 0).unwrap();
2871 assert!(result.text.is_none());
2874 }
2875
2876 #[test]
2881 fn test_indent_then_dedent_roundtrip() {
2882 let original = "hello world\nfoo bar\n";
2883
2884 let indent_op = Op::Indent {
2886 find: "hello".to_string(),
2887 amount: 4,
2888 use_tabs: false,
2889 regex: false,
2890 case_insensitive: false,
2891 };
2892 let indent_matcher = Matcher::new(&indent_op).unwrap();
2893 let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
2894 let indented_text = indented.text.unwrap();
2895 assert_eq!(indented_text, " hello world\nfoo bar\n");
2896
2897 let dedent_op = Op::Dedent {
2899 find: "hello".to_string(),
2900 amount: 4,
2901 use_tabs: false,
2902 regex: false,
2903 case_insensitive: false,
2904 };
2905 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
2906 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
2907 assert_eq!(dedented.text.unwrap(), original);
2908 }
2909
2910 #[test]
2915 fn test_transform_undo_stores_original() {
2916 let text = "hello world\n";
2917 let op = Op::Transform {
2918 find: "hello".to_string(),
2919 mode: TransformMode::Upper,
2920 regex: false,
2921 case_insensitive: false,
2922 };
2923 let matcher = Matcher::new(&op).unwrap();
2924 let result = apply(text, &op, &matcher, None, 0).unwrap();
2925 assert_eq!(result.undo.unwrap().original_text, text);
2926 }
2927
2928 #[test]
2929 fn test_surround_undo_stores_original() {
2930 let text = "hello world\n";
2931 let op = Op::Surround {
2932 find: "hello".to_string(),
2933 prefix: "<".to_string(),
2934 suffix: ">".to_string(),
2935 regex: false,
2936 case_insensitive: false,
2937 };
2938 let matcher = Matcher::new(&op).unwrap();
2939 let result = apply(text, &op, &matcher, None, 0).unwrap();
2940 assert_eq!(result.undo.unwrap().original_text, text);
2941 }
2942
2943 #[test]
2944 fn test_indent_undo_stores_original() {
2945 let text = "hello world\n";
2946 let op = Op::Indent {
2947 find: "hello".to_string(),
2948 amount: 4,
2949 use_tabs: false,
2950 regex: false,
2951 case_insensitive: false,
2952 };
2953 let matcher = Matcher::new(&op).unwrap();
2954 let result = apply(text, &op, &matcher, None, 0).unwrap();
2955 assert_eq!(result.undo.unwrap().original_text, text);
2956 }
2957
2958 #[test]
2959 fn test_dedent_undo_stores_original() {
2960 let text = " hello world\n";
2961 let op = Op::Dedent {
2962 find: "hello".to_string(),
2963 amount: 4,
2964 use_tabs: false,
2965 regex: false,
2966 case_insensitive: false,
2967 };
2968 let matcher = Matcher::new(&op).unwrap();
2969 let result = apply(text, &op, &matcher, None, 0).unwrap();
2970 assert_eq!(result.undo.unwrap().original_text, text);
2971 }
2972
2973 #[test]
2978 fn test_transform_preserves_line_count() {
2979 let text = "hello\nworld\nfoo\n";
2980 let op = Op::Transform {
2981 find: "hello".to_string(),
2982 mode: TransformMode::Upper,
2983 regex: false,
2984 case_insensitive: false,
2985 };
2986 let matcher = Matcher::new(&op).unwrap();
2987 let result = apply(text, &op, &matcher, None, 0).unwrap();
2988 let output = result.text.unwrap();
2989 assert_eq!(text.lines().count(), output.lines().count());
2990 }
2991
2992 #[test]
2993 fn test_surround_preserves_line_count() {
2994 let text = "hello\nworld\nfoo\n";
2995 let op = Op::Surround {
2996 find: "hello".to_string(),
2997 prefix: "<".to_string(),
2998 suffix: ">".to_string(),
2999 regex: false,
3000 case_insensitive: false,
3001 };
3002 let matcher = Matcher::new(&op).unwrap();
3003 let result = apply(text, &op, &matcher, None, 0).unwrap();
3004 let output = result.text.unwrap();
3005 assert_eq!(text.lines().count(), output.lines().count());
3006 }
3007
3008 #[test]
3009 fn test_indent_preserves_line_count() {
3010 let text = "hello\nworld\nfoo\n";
3011 let op = Op::Indent {
3012 find: "hello".to_string(),
3013 amount: 4,
3014 use_tabs: false,
3015 regex: false,
3016 case_insensitive: false,
3017 };
3018 let matcher = Matcher::new(&op).unwrap();
3019 let result = apply(text, &op, &matcher, None, 0).unwrap();
3020 let output = result.text.unwrap();
3021 assert_eq!(text.lines().count(), output.lines().count());
3022 }
3023
3024 #[test]
3025 fn test_dedent_preserves_line_count() {
3026 let text = " hello\n world\n foo\n";
3027 let op = Op::Dedent {
3028 find: "hello".to_string(),
3029 amount: 4,
3030 use_tabs: false,
3031 regex: false,
3032 case_insensitive: false,
3033 };
3034 let matcher = Matcher::new(&op).unwrap();
3035 let result = apply(text, &op, &matcher, None, 0).unwrap();
3036 let output = result.text.unwrap();
3037 assert_eq!(text.lines().count(), output.lines().count());
3038 }
3039
3040 #[test]
3049 fn test_line_numbers_with_mixed_line_endings() {
3050 let text = "alpha\nbeta\r\ngamma match\ndelta\r\n";
3055 let op = Op::Replace {
3056 count: Default::default(),
3057 multiline: false,
3058 find: "match".to_string(),
3059 replace: "HIT".to_string(),
3060 regex: false,
3061 case_insensitive: false,
3062 };
3063 let matcher = Matcher::new(&op).unwrap();
3064 let result = apply(text, &op, &matcher, None, 0).unwrap();
3065 assert_eq!(result.changes.len(), 1);
3066 assert_eq!(
3067 result.changes[0].line, 3,
3068 "Line number must be 3 regardless of the mixed CR/LF / CRLF endings above it; got {}",
3069 result.changes[0].line
3070 );
3071 assert_eq!(result.changes[0].before, "gamma match");
3072 }
3073
3074 #[test]
3078 fn test_line_number_for_single_line_no_newline() {
3079 let text = "only line matches here";
3080 let op = Op::Replace {
3081 count: Default::default(),
3082 multiline: false,
3083 find: "matches".to_string(),
3084 replace: "OK".to_string(),
3085 regex: false,
3086 case_insensitive: false,
3087 };
3088 let matcher = Matcher::new(&op).unwrap();
3089 let result = apply(text, &op, &matcher, None, 0).unwrap();
3090 assert_eq!(result.changes.len(), 1);
3091 assert_eq!(result.changes[0].line, 1);
3092 }
3093
3094 #[test]
3097 fn test_first_line_is_one_not_zero() {
3098 let text = "match first\nother\nother\n";
3099 let op = Op::Replace {
3100 count: Default::default(),
3101 multiline: false,
3102 find: "match".to_string(),
3103 replace: "X".to_string(),
3104 regex: false,
3105 case_insensitive: false,
3106 };
3107 let matcher = Matcher::new(&op).unwrap();
3108 let result = apply(text, &op, &matcher, None, 0).unwrap();
3109 assert_eq!(
3110 result.changes[0].line, 1,
3111 "First-line change must be reported as line 1, not 0"
3112 );
3113 }
3114
3115 #[test]
3120 fn test_delete_reports_original_line_number() {
3121 let text = "keep1\ndelete_me\nkeep2\n";
3122 let op = Op::Delete {
3123 multiline: false,
3124 find: "delete_me".to_string(),
3125 regex: false,
3126 case_insensitive: false,
3127 };
3128 let matcher = Matcher::new(&op).unwrap();
3129 let result = apply(text, &op, &matcher, None, 0).unwrap();
3130 assert_eq!(result.changes.len(), 1);
3131 assert_eq!(
3132 result.changes[0].line, 2,
3133 "Deleted line's reported line must be its original position (2)"
3134 );
3135 assert_eq!(result.changes[0].before, "delete_me");
3136 assert_eq!(result.changes[0].after, None);
3137 }
3138
3139 #[test]
3146 fn test_delete_all_lines_produces_empty_file() {
3147 let text = "only line\n";
3148 let op = Op::Delete {
3149 multiline: false,
3150 find: "only".to_string(),
3151 regex: false,
3152 case_insensitive: false,
3153 };
3154 let matcher = Matcher::new(&op).unwrap();
3155 let result = apply(text, &op, &matcher, None, 0).unwrap();
3156 let output = result.text.unwrap();
3157 assert_eq!(
3158 output, "",
3159 "Deleting every line must yield an empty file, got {output:?}"
3160 );
3161 }
3162
3163 #[test]
3165 fn test_delete_all_lines_crlf_produces_empty_file() {
3166 let text = "only line\r\n";
3167 let op = Op::Delete {
3168 multiline: false,
3169 find: "only".to_string(),
3170 regex: false,
3171 case_insensitive: false,
3172 };
3173 let matcher = Matcher::new(&op).unwrap();
3174 let result = apply(text, &op, &matcher, None, 0).unwrap();
3175 let output = result.text.unwrap();
3176 assert_eq!(
3177 output, "",
3178 "Deleting every CRLF line must yield an empty file"
3179 );
3180 }
3181}
3182
3183#[cfg(test)]
3187mod proptests {
3188 use super::*;
3189 use crate::matcher::Matcher;
3190 use crate::operation::Op;
3191 use proptest::prelude::*;
3192
3193 fn arb_multiline_text() -> impl Strategy<Value = String> {
3195 prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
3196 }
3197
3198 fn arb_find_pattern() -> impl Strategy<Value = String> {
3200 "[a-zA-Z0-9]{1,8}"
3201 }
3202
3203 proptest! {
3204 #[test]
3209 fn prop_splice_equals_line_path(
3210 text in arb_multiline_text(),
3211 find in arb_find_pattern(),
3212 replace in "[a-zA-Z0-9 ]{0,8}",
3213 case_insensitive in proptest::bool::ANY,
3214 ) {
3215 let op = Op::Replace {
3216 count: Default::default(),
3217 multiline: false,
3218 find,
3219 replace,
3220 regex: false,
3221 case_insensitive,
3222 };
3223 let matcher = Matcher::new(&op).unwrap();
3224 let spliced = apply(&text, &op, &matcher, None, 2).unwrap();
3225 let looped = apply(
3226 &text,
3227 &op,
3228 &matcher,
3229 Some(RangeSpec::Lines(LineRange { start: 1, end: None })),
3230 2,
3231 )
3232 .unwrap();
3233 prop_assert_eq!(spliced.text, looped.text);
3234 prop_assert_eq!(spliced.changes, looped.changes);
3235 }
3236
3237 #[test]
3240 fn prop_roundtrip_undo(
3241 text in arb_multiline_text(),
3242 find in arb_find_pattern(),
3243 replace in "[a-zA-Z0-9]{0,8}",
3244 ) {
3245 let op = Op::Replace {
3246 count: Default::default(),
3247 multiline: false,
3248 find: find.clone(),
3249 replace: replace.clone(),
3250 regex: false,
3251 case_insensitive: false,
3252 };
3253 let matcher = Matcher::new(&op).unwrap();
3254 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3255
3256 if let Some(undo) = &result.undo {
3257 prop_assert_eq!(&undo.original_text, &text);
3259 }
3260 if result.text.is_none() {
3262 prop_assert!(result.changes.is_empty());
3263 }
3264 }
3265
3266 #[test]
3268 fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
3269 let op = Op::Replace {
3272 count: Default::default(),
3273 multiline: false,
3274 find: "\x00\x00NOMATCH\x00\x00".to_string(),
3275 replace: "replacement".to_string(),
3276 regex: false,
3277 case_insensitive: false,
3278 };
3279 let matcher = Matcher::new(&op).unwrap();
3280 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3281 prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
3282 prop_assert!(result.changes.is_empty());
3283 prop_assert!(result.undo.is_none());
3284 }
3285
3286 #[test]
3288 fn prop_deterministic(
3289 text in arb_multiline_text(),
3290 find in arb_find_pattern(),
3291 replace in "[a-zA-Z0-9]{0,8}",
3292 ) {
3293 let op = Op::Replace {
3294 count: Default::default(),
3295 multiline: false,
3296 find,
3297 replace,
3298 regex: false,
3299 case_insensitive: false,
3300 };
3301 let matcher = Matcher::new(&op).unwrap();
3302 let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
3303 let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
3304 prop_assert_eq!(&r1.text, &r2.text);
3305 prop_assert_eq!(r1.changes.len(), r2.changes.len());
3306 }
3307
3308 #[test]
3310 fn prop_replace_preserves_line_count(
3311 text in arb_multiline_text(),
3312 find in arb_find_pattern(),
3313 replace in "[a-zA-Z0-9]{0,8}",
3314 ) {
3315 let op = Op::Replace {
3316 count: Default::default(),
3317 multiline: false,
3318 find,
3319 replace,
3320 regex: false,
3321 case_insensitive: false,
3322 };
3323 let matcher = Matcher::new(&op).unwrap();
3324 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3325 if let Some(ref output) = result.text {
3326 let input_lines = text.lines().count();
3327 let output_lines = output.lines().count();
3328 prop_assert_eq!(
3329 input_lines,
3330 output_lines,
3331 "Replace should preserve line count: input={} output={}",
3332 input_lines,
3333 output_lines
3334 );
3335 }
3336 }
3337
3338 #[test]
3341 fn prop_indent_dedent_roundtrip(
3342 amount in 1usize..=16,
3343 ) {
3344 let find = "marker".to_string();
3346 let text = "marker line one\nmarker line two\nmarker line three\n";
3347
3348 let indent_op = Op::Indent {
3349 find: find.clone(),
3350 amount,
3351 use_tabs: false,
3352 regex: false,
3353 case_insensitive: false,
3354 };
3355 let indent_matcher = Matcher::new(&indent_op).unwrap();
3356 let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
3357 let indented_text = indented.text.unwrap();
3358
3359 for line in indented_text.lines() {
3361 let leading = line.len() - line.trim_start_matches(' ').len();
3362 prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
3363 }
3364
3365 let dedent_op = Op::Dedent {
3366 find: find.clone(),
3367 amount,
3368 use_tabs: false,
3369 regex: false,
3370 case_insensitive: false,
3371 };
3372 let dedent_matcher = Matcher::new(&dedent_op).unwrap();
3373 let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
3374 prop_assert_eq!(dedented.text.unwrap(), text);
3375 }
3376
3377 #[test]
3380 fn prop_transform_upper_lower_roundtrip(
3381 find in "[a-z]{1,8}",
3382 ) {
3383 let text = format!("prefix {find} suffix\n");
3384
3385 let upper_op = Op::Transform {
3386 find: find.clone(),
3387 mode: crate::operation::TransformMode::Upper,
3388 regex: false,
3389 case_insensitive: false,
3390 };
3391 let upper_matcher = Matcher::new(&upper_op).unwrap();
3392 let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
3393
3394 if let Some(ref upper_text) = uppered.text {
3395 let upper_find = find.to_uppercase();
3396 let lower_op = Op::Transform {
3397 find: upper_find,
3398 mode: crate::operation::TransformMode::Lower,
3399 regex: false,
3400 case_insensitive: false,
3401 };
3402 let lower_matcher = Matcher::new(&lower_op).unwrap();
3403 let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
3404 prop_assert_eq!(lowered.text.unwrap(), text);
3405 }
3406 }
3407
3408 #[test]
3410 fn prop_surround_preserves_line_count(
3411 text in arb_multiline_text(),
3412 find in arb_find_pattern(),
3413 ) {
3414 let op = Op::Surround {
3415 find,
3416 prefix: "<<".to_string(),
3417 suffix: ">>".to_string(),
3418 regex: false,
3419 case_insensitive: false,
3420 };
3421 let matcher = Matcher::new(&op).unwrap();
3422 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3423 if let Some(ref output) = result.text {
3424 let input_lines = text.lines().count();
3425 let output_lines = output.lines().count();
3426 prop_assert_eq!(
3427 input_lines,
3428 output_lines,
3429 "Surround should preserve line count: input={} output={}",
3430 input_lines,
3431 output_lines
3432 );
3433 }
3434 }
3435
3436 #[test]
3438 fn prop_transform_preserves_line_count(
3439 text in arb_multiline_text(),
3440 find in arb_find_pattern(),
3441 ) {
3442 let op = Op::Transform {
3443 find,
3444 mode: crate::operation::TransformMode::Upper,
3445 regex: false,
3446 case_insensitive: false,
3447 };
3448 let matcher = Matcher::new(&op).unwrap();
3449 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3450 if let Some(ref output) = result.text {
3451 let input_lines = text.lines().count();
3452 let output_lines = output.lines().count();
3453 prop_assert_eq!(
3454 input_lines,
3455 output_lines,
3456 "Transform should preserve line count: input={} output={}",
3457 input_lines,
3458 output_lines
3459 );
3460 }
3461 }
3462
3463 #[test]
3465 fn prop_indent_preserves_line_count(
3466 text in arb_multiline_text(),
3467 find in arb_find_pattern(),
3468 amount in 1usize..=16,
3469 ) {
3470 let op = Op::Indent {
3471 find,
3472 amount,
3473 use_tabs: false,
3474 regex: false,
3475 case_insensitive: false,
3476 };
3477 let matcher = Matcher::new(&op).unwrap();
3478 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3479 if let Some(ref output) = result.text {
3480 let input_lines = text.lines().count();
3481 let output_lines = output.lines().count();
3482 prop_assert_eq!(
3483 input_lines,
3484 output_lines,
3485 "Indent should preserve line count: input={} output={}",
3486 input_lines,
3487 output_lines
3488 );
3489 }
3490 }
3491
3492 #[test]
3497 fn prop_change_lines_ascending_and_in_bounds(
3498 text in arb_multiline_text(),
3499 find in arb_find_pattern(),
3500 replace in "[a-zA-Z0-9]{0,8}",
3501 ) {
3502 let op = Op::Replace {
3503 count: Default::default(),
3504 multiline: false,
3505 find,
3506 replace,
3507 regex: false,
3508 case_insensitive: false,
3509 };
3510 let matcher = Matcher::new(&op).unwrap();
3511 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3512 let max_line = text.lines().count();
3513 let mut prev: usize = 0;
3514 for change in &result.changes {
3515 prop_assert!(change.line >= 1, "Line numbers must be 1-indexed, got {}", change.line);
3516 prop_assert!(
3517 change.line <= max_line,
3518 "Line {} out of input range (max {})",
3519 change.line,
3520 max_line
3521 );
3522 prop_assert!(
3523 change.line > prev,
3524 "Changes must be strictly ascending by line: prev={} current={}",
3525 prev,
3526 change.line
3527 );
3528 prev = change.line;
3529 }
3530 }
3531
3532 #[test]
3536 fn prop_delete_exact_line_count(
3537 find in arb_find_pattern(),
3541 match_count in 0usize..=8,
3542 nonmatch_count in 0usize..=8,
3543 ) {
3544 let match_lines: Vec<String> = (0..match_count)
3545 .map(|_| format!("-- {} --", find))
3546 .collect();
3547 let nonmatch_lines: Vec<String> = (0..nonmatch_count)
3550 .map(|_| "--- filler ---".to_string())
3551 .collect();
3552 for l in &nonmatch_lines {
3554 prop_assume!(!l.contains(&find));
3555 }
3556 let mut merged = Vec::with_capacity(match_count + nonmatch_count);
3558 let mut mi = match_lines.iter();
3559 let mut ni = nonmatch_lines.iter();
3560 loop {
3561 let m = mi.next();
3562 let n = ni.next();
3563 if m.is_none() && n.is_none() { break; }
3564 if let Some(m) = m { merged.push(m.clone()); }
3565 if let Some(n) = n { merged.push(n.clone()); }
3566 }
3567 if merged.is_empty() {
3568 return Ok(());
3570 }
3571 let text = merged.join("\n") + "\n";
3572
3573 let op = Op::Delete {
3574 multiline: false,
3575 find: find.clone(),
3576 regex: false,
3577 case_insensitive: false,
3578 };
3579 let matcher = Matcher::new(&op).unwrap();
3580 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3581
3582 let expected_deletions: usize = text.lines().filter(|l| l.contains(&find)).count();
3583 prop_assert_eq!(
3584 expected_deletions,
3585 match_count,
3586 "test construction bug: matcher-count must equal match_count"
3587 );
3588
3589 if expected_deletions == 0 {
3590 prop_assert!(result.text.is_none());
3591 prop_assert!(result.changes.is_empty());
3592 } else {
3593 let input_lines = text.lines().count();
3594 let output_lines = result.text.as_ref().unwrap().lines().count();
3595 prop_assert_eq!(
3596 input_lines - expected_deletions,
3597 output_lines,
3598 "Delete removed wrong number of lines: expected {} - {} = {}, got {}",
3599 input_lines,
3600 expected_deletions,
3601 input_lines - expected_deletions,
3602 output_lines
3603 );
3604 prop_assert_eq!(result.changes.len(), expected_deletions);
3605 }
3606 }
3607
3608 #[test]
3612 fn prop_crlf_majority_preserved(
3613 find in "[a-zA-Z]{1,6}",
3614 replace in "[a-zA-Z]{0,6}",
3615 ) {
3616 let text = format!(
3619 "line {find} one\r\n{find} middle\r\nanother {find} here\r\nending\r\n"
3620 );
3621 let op = Op::Replace {
3622 count: Default::default(),
3623 multiline: false,
3624 find: find.clone(),
3625 replace,
3626 regex: false,
3627 case_insensitive: false,
3628 };
3629 let matcher = Matcher::new(&op).unwrap();
3630 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3631 if let Some(ref output) = result.text {
3632 let crlf = output.matches("\r\n").count();
3633 let bare_lf = output.matches('\n').count() - crlf;
3634 prop_assert!(
3635 crlf > bare_lf,
3636 "CRLF majority lost: {crlf} CRLF vs {bare_lf} bare LF in {output:?}"
3637 );
3638 }
3639 }
3640
3641 #[test]
3646 fn prop_replace_change_count_matches_containing_lines(
3647 find in arb_find_pattern(),
3648 replace in "[a-zA-Z]{0,8}",
3649 ) {
3650 let text = format!(
3652 "head\n{find} one\nmiddle\n{find} two {find}\ntail\n"
3653 );
3654 let op = Op::Replace {
3655 count: Default::default(),
3656 multiline: false,
3657 find: find.clone(),
3658 replace,
3659 regex: false,
3660 case_insensitive: false,
3661 };
3662 let matcher = Matcher::new(&op).unwrap();
3663 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3664
3665 let expected: usize = text.lines().filter(|l| l.contains(&find)).count();
3666 prop_assert_eq!(result.changes.len(), expected);
3667 }
3668
3669 #[test]
3673 fn prop_no_trailing_newline_preserved(
3674 find in "[a-zA-Z]{1,6}",
3675 replace in "[a-zA-Z]{1,6}",
3676 ) {
3677 let text = format!("first line\nlast line with {find}");
3680 prop_assume!(!text.ends_with('\n'));
3681
3682 let op = Op::Replace {
3683 count: Default::default(),
3684 multiline: false,
3685 find,
3686 replace,
3687 regex: false,
3688 case_insensitive: false,
3689 };
3690 let matcher = Matcher::new(&op).unwrap();
3691 let result = apply(&text, &op, &matcher, None, 0).unwrap();
3692 if let Some(ref output) = result.text {
3693 prop_assert!(
3694 !output.ends_with('\n'),
3695 "Spurious trailing newline added to {output:?} (input had none)"
3696 );
3697 }
3698 }
3699 }
3700}