1use crate::mismatch::HashlineError;
13use crate::types::{Anchor, ApplyResult, Cursor, Edit, InsertMode};
14use std::collections::{BTreeMap, HashSet};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22struct DelimiterBalance {
23 paren: i32,
24 bracket: i32,
25 brace: i32,
26}
27
28fn compute_delimiter_balance(lines: &[String]) -> DelimiterBalance {
37 let mut bal = DelimiterBalance::default();
38 let mut in_block_comment = false;
39 let mut quote: Option<u8> = None;
40
41 for line in lines {
42 let bytes = line.as_bytes();
43 let mut i = 0;
44 while i < bytes.len() {
45 let ch = bytes[i];
46 if in_block_comment {
47 if ch == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
48 in_block_comment = false;
49 i += 1;
50 }
51 i += 1;
52 continue;
53 }
54 if let Some(q) = quote {
55 if ch == b'\\' {
56 i += 2; } else if ch == q {
58 quote = None;
59 i += 1;
60 } else {
61 i += 1;
62 }
63 continue;
64 }
65 match ch {
66 b'"' | b'\'' | b'`' => {
67 quote = Some(ch);
68 i += 1;
69 }
70 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => break, b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
72 in_block_comment = true;
73 i += 2;
74 }
75 b'(' => {
76 bal.paren += 1;
77 i += 1;
78 }
79 b')' => {
80 bal.paren -= 1;
81 i += 1;
82 }
83 b'[' => {
84 bal.bracket += 1;
85 i += 1;
86 }
87 b']' => {
88 bal.bracket -= 1;
89 i += 1;
90 }
91 b'{' => {
92 bal.brace += 1;
93 i += 1;
94 }
95 b'}' => {
96 bal.brace -= 1;
97 i += 1;
98 }
99 _ => i += 1,
100 }
101 }
102 if quote == Some(b'"') || quote == Some(b'\'') {
104 quote = None;
105 }
106 }
107 bal
108}
109
110fn balance_delta(a: DelimiterBalance, b: DelimiterBalance) -> DelimiterBalance {
111 DelimiterBalance {
112 paren: a.paren - b.paren,
113 bracket: a.bracket - b.bracket,
114 brace: a.brace - b.brace,
115 }
116}
117
118fn balance_negate(a: DelimiterBalance) -> DelimiterBalance {
119 DelimiterBalance {
120 paren: -a.paren,
121 bracket: -a.bracket,
122 brace: -a.brace,
123 }
124}
125
126fn balance_is_zero(a: DelimiterBalance) -> bool {
127 a.paren == 0 && a.bracket == 0 && a.brace == 0
128}
129
130fn is_bracket_closer_line(text: &str) -> bool {
137 let trimmed = text.trim();
138 if trimmed.is_empty() {
139 return false;
140 }
141 let bytes = trimmed.as_bytes();
142 let first = bytes[0];
143 if first != b')' && first != b']' && first != b'}' {
144 return false;
145 }
146 let mut i = 0;
147 while i < bytes.len() {
148 match bytes[i] {
149 b')' | b']' | b'}' => i += 1,
150 _ => break,
151 }
152 }
153 if i < bytes.len() && (bytes[i] == b';' || bytes[i] == b',') {
154 i += 1;
155 }
156 i == bytes.len()
157}
158
159fn is_jsx_name_byte(b: u8) -> bool {
162 b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b':' || b == b'-'
163}
164
165fn is_valid_jsx_name(name: &str) -> bool {
167 let bytes = name.as_bytes();
168 if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
169 return false;
170 }
171 bytes[1..].iter().all(|&b| is_jsx_name_byte(b))
172}
173
174fn is_jsx_closer_line(text: &str) -> bool {
176 let s = strip_optional_trailing_punct(text.trim());
177 if s == "</>" || s == "/>" {
178 return true;
179 }
180 if let Some(rest) = s.strip_prefix("</")
181 && let Some(name) = rest.strip_suffix('>')
182 {
183 return is_valid_jsx_name(name);
184 }
185 false
186}
187
188fn is_structural_closer_line(text: &str) -> bool {
190 is_bracket_closer_line(text) || is_jsx_closer_line(text)
191}
192
193fn jsx_closer_name(text: &str) -> Option<String> {
196 let s = strip_optional_trailing_punct(text.trim());
197 if s == "</>" {
198 return Some(String::new());
199 }
200 let rest = s.strip_prefix("</")?;
201 let name = rest.strip_suffix('>')?;
202 if is_valid_jsx_name(name) {
203 Some(name.to_string())
204 } else {
205 None
206 }
207}
208
209fn strip_optional_trailing_punct(s: &str) -> &str {
212 if s.ends_with(';') || s.ends_with(',') {
213 s[..s.len() - 1].trim_end()
214 } else {
215 s
216 }
217}
218
219#[derive(Debug)]
224struct JsxPayloadTag {
225 name: String,
226 closing: bool,
227 self_closing: bool,
228}
229
230fn is_jsx_tag_start(bytes: &[u8], index: usize) -> bool {
231 match bytes.get(index + 1) {
232 Some(&b) => b == b'>' || b == b'/' || b.is_ascii_alphabetic(),
233 None => false,
234 }
235}
236
237fn find_jsx_tag_end(text: &str, start: usize) -> Option<usize> {
241 let bytes = text.as_bytes();
242 let mut quote: Option<u8> = None;
243 let mut braces = 0i32;
244 let mut i = start + 1;
245 while i < bytes.len() {
246 let ch = bytes[i];
247 if let Some(q) = quote {
248 if ch == b'\\' && i + 1 < bytes.len() {
249 i += 1; } else if ch == q {
251 quote = None;
252 }
253 i += 1;
254 continue;
255 }
256 match ch {
257 b'"' | b'\'' | b'`' => quote = Some(ch),
258 b'{' => braces += 1,
259 b'}' if braces > 0 => braces -= 1,
260 b'>' if braces == 0 => return Some(i),
261 _ => {}
262 }
263 i += 1;
264 }
265 None
266}
267
268fn parse_jsx_payload_tag(raw: &str) -> Option<JsxPayloadTag> {
269 if raw == "<>" {
270 return Some(JsxPayloadTag {
271 name: String::new(),
272 closing: false,
273 self_closing: false,
274 });
275 }
276 if raw == "</>" {
277 return Some(JsxPayloadTag {
278 name: String::new(),
279 closing: true,
280 self_closing: false,
281 });
282 }
283 let closing = raw.starts_with("</");
284 let name_start = if closing { 2 } else { 1 };
285 let bytes = raw.as_bytes();
286 let mut name_end = name_start;
287 while name_end < bytes.len() && is_jsx_name_byte(bytes[name_end]) {
288 name_end += 1;
289 }
290 if name_end == name_start {
291 return None;
292 }
293 let name = raw[name_start..name_end].to_string();
294 let self_closing = !closing && raw.ends_with("/>");
295 Some(JsxPayloadTag {
296 name,
297 closing,
298 self_closing,
299 })
300}
301
302fn read_jsx_payload_tags(text: &str) -> Vec<JsxPayloadTag> {
303 let bytes = text.as_bytes();
304 let mut tags = Vec::new();
305 let mut pos = 0;
306 while let Some(p) = bytes[pos..].iter().position(|&b| b == b'<') {
307 let start = pos + p;
308 pos = start + 1;
309 if !is_jsx_tag_start(bytes, start) {
310 continue;
311 }
312 let end = match find_jsx_tag_end(text, start) {
313 Some(e) => e,
314 None => break,
315 };
316 let raw = &text[start..=end];
317 if let Some(tag) = parse_jsx_payload_tag(raw) {
318 tags.push(tag);
319 }
320 pos = end + 1;
321 }
322 tags
323}
324
325fn payload_has_jsx_opener_for_echo(payload_prefix: &[String], echo_lines: &[String]) -> bool {
328 let joined = payload_prefix.join("\n");
329 let mut open_tags: Vec<String> = Vec::new();
330 for tag in read_jsx_payload_tags(&joined) {
331 if tag.closing {
332 if open_tags.last().map(|n| n == &tag.name).unwrap_or(false) {
333 open_tags.pop();
334 }
335 } else if !tag.self_closing {
336 open_tags.push(tag.name);
337 }
338 }
339 echo_lines
340 .iter()
341 .any(|line| jsx_closer_name(line).is_some_and(|name| open_tags.contains(&name)))
342}
343
344struct ReplacementGroup {
351 insert_indices: Vec<usize>,
353 delete_indices: Vec<usize>,
355 payload: Vec<String>,
356 start_line: u32,
358 end_line: u32,
360}
361
362fn find_replacement_group(edits: &[Edit], start: usize) -> Option<ReplacementGroup> {
364 let first = edits.get(start)?;
365 let (line_num, anchor_line) = match first {
366 Edit::Insert {
367 mode: Some(InsertMode::Replacement),
368 cursor: Cursor::BeforeAnchor(anchor),
369 line_num,
370 ..
371 } => (*line_num, anchor.line),
372 _ => return None,
373 };
374
375 let mut insert_indices = Vec::new();
376 let mut payload = Vec::new();
377 let mut i = start;
378 while i < edits.len() {
379 let edit = &edits[i];
380 match edit {
381 Edit::Insert {
382 mode: Some(InsertMode::Replacement),
383 cursor: Cursor::BeforeAnchor(a),
384 line_num: ln,
385 text,
386 ..
387 } if *ln == line_num && a.line == anchor_line => {
388 insert_indices.push(i);
389 payload.push(text.clone());
390 i += 1;
391 }
392 _ => break,
393 }
394 }
395
396 let mut delete_indices = Vec::new();
397 let mut expected_line = anchor_line;
398 while i < edits.len() {
399 let edit = &edits[i];
400 match edit {
401 Edit::Delete {
402 anchor,
403 line_num: ln,
404 ..
405 } if *ln == line_num && anchor.line == expected_line => {
406 delete_indices.push(i);
407 expected_line += 1;
408 i += 1;
409 }
410 _ => break,
411 }
412 }
413
414 if delete_indices.is_empty() {
415 return None;
416 }
417 let end_line = anchor_line + delete_indices.len() as u32 - 1;
418
419 Some(ReplacementGroup {
420 insert_indices,
421 delete_indices,
422 payload,
423 start_line: anchor_line,
424 end_line,
425 })
426}
427
428fn has_non_whitespace(text: &str) -> bool {
433 text.bytes()
434 .any(|b| !matches!(b, b'\t' | b'\n' | 0x0B | 0x0C | b'\r' | b' '))
435}
436
437fn count_duplicate_leading_boundary_lines(
441 group: &ReplacementGroup,
442 file_lines: &[String],
443) -> usize {
444 let payload_len = group.payload.len();
445 let max = payload_len.min((group.start_line - 1) as usize);
446 for count in (1..=max).rev() {
447 let mut matches = true;
448 let mut has_content = false;
449 for offset in 0..count {
450 let payload_line = &group.payload[offset];
451 let file_idx = (group.start_line - 1) as usize - count + offset;
452 if payload_line != &file_lines[file_idx] {
453 matches = false;
454 break;
455 }
456 if has_non_whitespace(payload_line) {
457 has_content = true;
458 }
459 }
460 if matches && has_content {
461 return count;
462 }
463 }
464 0
465}
466
467fn count_duplicate_trailing_boundary_lines(
471 group: &ReplacementGroup,
472 file_lines: &[String],
473) -> usize {
474 let payload_len = group.payload.len();
475 let max = payload_len.min(file_lines.len().saturating_sub(group.end_line as usize));
476 for count in (1..=max).rev() {
477 let mut matches = true;
478 let mut has_content = false;
479 for offset in 0..count {
480 let payload_idx = payload_len - count + offset;
481 let payload_line = &group.payload[payload_idx];
482 let file_idx = group.end_line as usize + offset;
483 if payload_line != &file_lines[file_idx] {
484 matches = false;
485 break;
486 }
487 if has_non_whitespace(payload_line) {
488 has_content = true;
489 }
490 }
491 if matches && has_content {
492 return count;
493 }
494 }
495 0
496}
497
498struct BoundaryEcho {
499 leading: usize,
500 trailing: usize,
501}
502
503fn find_boundary_echo(group: &ReplacementGroup, file_lines: &[String]) -> Option<BoundaryEcho> {
507 let leading_max = count_duplicate_leading_boundary_lines(group, file_lines);
508 if leading_max == 0 {
509 return None;
510 }
511 let trailing_max = count_duplicate_trailing_boundary_lines(group, file_lines);
512 if trailing_max == 0 {
513 return None;
514 }
515 if leading_max + trailing_max >= group.payload.len() {
516 return None;
517 }
518
519 let leading_balance = compute_delimiter_balance(&group.payload[..leading_max]);
520 let trailing_balance =
521 compute_delimiter_balance(&group.payload[group.payload.len() - trailing_max..]);
522 let dropped_balance = balance_delta(leading_balance, balance_negate(trailing_balance));
523
524 if !balance_is_zero(dropped_balance) {
525 let delta = balance_delta(
526 compute_delimiter_balance(&group.payload),
527 compute_delimiter_balance(
528 &file_lines[(group.start_line - 1) as usize..group.end_line as usize],
529 ),
530 );
531 if dropped_balance != delta {
532 return None;
533 }
534 }
535 Some(BoundaryEcho {
536 leading: leading_max,
537 trailing: trailing_max,
538 })
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546enum EchoSide {
547 Leading,
548 Trailing,
549}
550
551struct OneSidedEcho {
552 side: EchoSide,
553 count: usize,
554}
555
556fn find_one_sided_boundary_echo(
561 group: &ReplacementGroup,
562 file_lines: &[String],
563) -> Option<OneSidedEcho> {
564 let leading = count_duplicate_leading_boundary_lines(group, file_lines);
565 let trailing = count_duplicate_trailing_boundary_lines(group, file_lines);
566 if (leading > 0) == (trailing > 0) {
567 return None;
568 }
569 let (side, count) = if leading > 0 {
570 (EchoSide::Leading, leading)
571 } else {
572 (EchoSide::Trailing, trailing)
573 };
574 if count >= group.payload.len() {
575 return None;
576 }
577 let echo_lines: &[String] = match side {
578 EchoSide::Leading => &group.payload[..count],
579 EchoSide::Trailing => &group.payload[group.payload.len() - count..],
580 };
581 if !balance_is_zero(compute_delimiter_balance(echo_lines)) {
582 return None;
583 }
584 if group.delete_indices.len() <= 1 {
585 if side != EchoSide::Trailing {
586 return None;
587 }
588 if !echo_lines.iter().all(|l| is_structural_closer_line(l)) {
589 return None;
590 }
591 let payload_prefix = &group.payload[..group.payload.len() - count];
592 if payload_has_jsx_opener_for_echo(payload_prefix, echo_lines) {
593 return None;
594 }
595 }
596 Some(OneSidedEcho { side, count })
597}
598
599fn find_duplicate_suffix(
606 group: &ReplacementGroup,
607 file_lines: &[String],
608 delta: DelimiterBalance,
609) -> usize {
610 if balance_is_zero(delta) {
611 return 0;
612 }
613 let payload_len = group.payload.len();
614 let max_k = payload_len.min(file_lines.len().saturating_sub(group.end_line as usize));
615 for k in (1..=max_k).rev() {
616 let mut matches = true;
617 for t in 0..k {
618 let payload_idx = payload_len - k + t;
619 let file_idx = group.end_line as usize + t;
620 if group.payload[payload_idx] != file_lines[file_idx] {
621 matches = false;
622 break;
623 }
624 }
625 if !matches {
626 continue;
627 }
628 let suffix = &group.payload[payload_len - k..];
629 if compute_delimiter_balance(suffix) == delta {
630 return k;
631 }
632 }
633 0
634}
635
636fn find_duplicate_prefix(
639 group: &ReplacementGroup,
640 file_lines: &[String],
641 delta: DelimiterBalance,
642) -> usize {
643 if balance_is_zero(delta) {
644 return 0;
645 }
646 let payload_len = group.payload.len();
647 let max_j = payload_len.min((group.start_line - 1) as usize);
648 for j in (1..=max_j).rev() {
649 let mut matches = true;
650 for t in 0..j {
651 let file_idx = (group.start_line - 1) as usize - j + t;
652 if group.payload[t] != file_lines[file_idx] {
653 matches = false;
654 break;
655 }
656 }
657 if !matches {
658 continue;
659 }
660 let prefix = &group.payload[..j];
661 if compute_delimiter_balance(prefix) == delta {
662 return j;
663 }
664 }
665 0
666}
667
668fn payload_ends_with_deleted_suffix(
669 group: &ReplacementGroup,
670 file_lines: &[String],
671 count: usize,
672) -> bool {
673 if group.payload.len() < count {
674 return false;
675 }
676 let deleted_start = group.end_line as usize - count;
677 let payload_start = group.payload.len() - count;
678 for offset in 0..count {
679 if group.payload[payload_start + offset] != file_lines[deleted_start + offset] {
680 return false;
681 }
682 }
683 true
684}
685
686fn find_dropped_suffix_closers(
690 group: &ReplacementGroup,
691 file_lines: &[String],
692 delta: DelimiterBalance,
693) -> usize {
694 let wanted = balance_negate(delta);
695 let max_m = group.delete_indices.len();
696 for m in 1..=max_m {
697 let idx = group.end_line as usize - m;
698 let line = file_lines.get(idx).map(|s| s.as_str()).unwrap_or("");
699 if !is_bracket_closer_line(line) {
700 break;
701 }
702 if payload_ends_with_deleted_suffix(group, file_lines, m) {
703 continue;
704 }
705 let suffix = &file_lines[group.end_line as usize - m..group.end_line as usize];
706 if compute_delimiter_balance(suffix) == wanted {
707 return m;
708 }
709 }
710 0
711}
712
713fn describe_boundary_echo_repair(group: &ReplacementGroup, echo: &BoundaryEcho) -> String {
718 format!(
719 "Auto-repaired a replacement boundary echo at line {start}: \
720 dropped {leading} leading and {trailing} trailing payload line(s) \
721 already present outside the range. \
722 Issue the payload as the final desired content for the selected range only \
723 — never restate unchanged lines bordering the range.",
724 start = group.start_line,
725 leading = echo.leading,
726 trailing = echo.trailing,
727 )
728}
729
730fn describe_boundary_repair(group: &ReplacementGroup, action: &str) -> String {
731 format!(
732 "Auto-repaired a delimiter-balance mismatch in the replacement at line {start}: {action}. \
733 Issue the payload as the final desired content only \
734 — never restate or omit a closing bracket bordering the range.",
735 start = group.start_line,
736 )
737}
738
739fn describe_one_sided_echo_repair(
740 group: &ReplacementGroup,
741 side: EchoSide,
742 count: usize,
743) -> String {
744 let (side_str, where_str) = match side {
745 EchoSide::Leading => ("leading", "above"),
746 EchoSide::Trailing => ("trailing", "below"),
747 };
748 format!(
749 "Auto-repaired a replacement boundary echo at line {start}: \
750 dropped {count} {side} payload line(s) identical to the surviving line(s) just {where} the range. \
751 The range was one line short of the content you retyped — \
752 issue the payload as the final content for the selected range only, \
753 and widen the range to consume any keeper you restate.",
754 start = group.start_line,
755 side = side_str,
756 where = where_str,
757 )
758}
759
760fn after_insert_landing_shift_warning(
761 anchor_line: u32,
762 landing_line: u32,
763 crossed: usize,
764) -> String {
765 let plural = if crossed == 1 { "" } else { "s" };
766 format!(
767 "INS.POST {anchor}: body indented shallower than the anchor, \
768 so the landing moved past {crossed} closing line{plural} to after line {landing}. \
769 For the deeper position inside the block, re-issue with the body indented to match.",
770 anchor = anchor_line,
771 crossed = crossed,
772 plural = plural,
773 landing = landing_line,
774 )
775}
776
777fn repair_replacement_boundaries(
784 edits: Vec<Edit>,
785 file_lines: &[String],
786) -> (Vec<Edit>, Vec<String>) {
787 let mut out: Vec<Edit> = Vec::with_capacity(edits.len());
788 let mut warnings: Vec<String> = Vec::new();
789 let mut i = 0;
790 while i < edits.len() {
791 let group = match find_replacement_group(&edits, i) {
792 Some(g) => g,
793 None => {
794 out.push(edits[i].clone());
795 i += 1;
796 continue;
797 }
798 };
799 i = group.delete_indices[group.delete_indices.len() - 1] + 1;
800
801 let push_inserts = |out: &mut Vec<Edit>, edits: &[Edit], range: std::ops::Range<usize>| {
802 for &idx in &group.insert_indices[range] {
803 out.push(edits[idx].clone());
804 }
805 };
806 let push_all_deletes = |out: &mut Vec<Edit>, edits: &[Edit]| {
807 for &idx in &group.delete_indices {
808 out.push(edits[idx].clone());
809 }
810 };
811
812 if let Some(echo) = find_boundary_echo(&group, file_lines) {
814 warnings.push(describe_boundary_echo_repair(&group, &echo));
815 push_inserts(
816 &mut out,
817 &edits,
818 echo.leading..group.insert_indices.len() - echo.trailing,
819 );
820 push_all_deletes(&mut out, &edits);
821 continue;
822 }
823
824 let delta = balance_delta(
825 compute_delimiter_balance(&group.payload),
826 compute_delimiter_balance(
827 &file_lines[(group.start_line - 1) as usize..group.end_line as usize],
828 ),
829 );
830
831 if balance_is_zero(delta) {
832 if let Some(one_sided) = find_one_sided_boundary_echo(&group, file_lines) {
834 warnings.push(describe_one_sided_echo_repair(
835 &group,
836 one_sided.side,
837 one_sided.count,
838 ));
839 match one_sided.side {
840 EchoSide::Leading => {
841 push_inserts(
842 &mut out,
843 &edits,
844 one_sided.count..group.insert_indices.len(),
845 );
846 }
847 EchoSide::Trailing => {
848 push_inserts(
849 &mut out,
850 &edits,
851 0..group.insert_indices.len() - one_sided.count,
852 );
853 }
854 }
855 push_all_deletes(&mut out, &edits);
856 continue;
857 }
858 push_inserts(&mut out, &edits, 0..group.insert_indices.len());
859 push_all_deletes(&mut out, &edits);
860 continue;
861 }
862
863 let dup_suffix = find_duplicate_suffix(&group, file_lines, delta);
865 if dup_suffix > 0 {
866 warnings.push(describe_boundary_repair(
867 &group,
868 &format!(
869 "dropped {dup_suffix} duplicated trailing payload line(s) already present below the range"
870 ),
871 ));
872 push_inserts(&mut out, &edits, 0..group.insert_indices.len() - dup_suffix);
873 push_all_deletes(&mut out, &edits);
874 continue;
875 }
876
877 let dup_prefix = find_duplicate_prefix(&group, file_lines, delta);
879 if dup_prefix > 0 {
880 warnings.push(describe_boundary_repair(
881 &group,
882 &format!(
883 "dropped {dup_prefix} duplicated leading payload line(s) already present above the range"
884 ),
885 ));
886 push_inserts(&mut out, &edits, dup_prefix..group.insert_indices.len());
887 push_all_deletes(&mut out, &edits);
888 continue;
889 }
890
891 let dropped_closers = find_dropped_suffix_closers(&group, file_lines, delta);
893 if dropped_closers > 0 {
894 warnings.push(describe_boundary_repair(
895 &group,
896 &format!(
897 "kept {dropped_closers} structural closing line(s) the range deleted without restating"
898 ),
899 ));
900 push_inserts(&mut out, &edits, 0..group.insert_indices.len());
901 for &idx in &group.delete_indices[..group.delete_indices.len() - dropped_closers] {
902 out.push(edits[idx].clone());
903 }
904 continue;
905 }
906
907 push_inserts(&mut out, &edits, 0..group.insert_indices.len());
908 push_all_deletes(&mut out, &edits);
909 }
910 (out, warnings)
911}
912
913fn leading_indent(s: &str) -> &str {
919 let end = s
920 .bytes()
921 .position(|b| b != b'\t' && b != b' ')
922 .unwrap_or(s.len());
923 &s[..end]
924}
925
926fn is_indent_deeper(deeper: &str, shallower: &str) -> bool {
928 deeper.len() > shallower.len() && deeper.starts_with(shallower)
929}
930
931struct AfterInsertGroup {
934 anchor: u32,
935 members: Vec<usize>,
936}
937
938fn body_target_indent(rows: &[String]) -> Option<&str> {
941 let non_blank: Vec<&str> = rows
942 .iter()
943 .filter(|r| has_non_whitespace(r))
944 .map(|s| s.as_str())
945 .collect();
946 if non_blank.is_empty() {
947 return None;
948 }
949 if non_blank.iter().all(|r| is_bracket_closer_line(r)) {
950 return None;
951 }
952 let first_indent = leading_indent(non_blank[0]);
953 let mut target = first_indent;
954 for &row in &non_blank {
955 let indent = leading_indent(row);
956 if indent.starts_with(target) {
957 continue;
958 }
959 if target.starts_with(indent) {
960 target = &first_indent[..indent.len()];
961 } else {
962 return None;
963 }
964 }
965 Some(target)
966}
967
968fn resolve_shifted_landing(
972 group: &AfterInsertGroup,
973 target: &str,
974 file_lines: &[String],
975 targeted_lines: &HashSet<u32>,
976) -> Option<(u32, usize)> {
977 let anchor_idx = (group.anchor - 1) as usize;
978 let anchor_text = file_lines.get(anchor_idx)?;
979 if !has_non_whitespace(anchor_text) {
980 return None;
981 }
982 let anchor_indent = leading_indent(anchor_text);
983 if !is_indent_deeper(anchor_indent, target) {
984 return None;
985 }
986
987 let mut landing = group.anchor;
988 let mut crossed = 0usize;
989 let mut line = group.anchor + 1;
990 while (line as usize) <= file_lines.len() {
991 let text = file_lines
992 .get((line - 1) as usize)
993 .map(|s| s.as_str())
994 .unwrap_or("");
995 if !has_non_whitespace(text) {
996 line += 1;
997 continue;
998 }
999 if !is_bracket_closer_line(text) {
1000 break;
1001 }
1002 let indent = leading_indent(text);
1003 if !indent.starts_with(target) {
1004 break;
1005 }
1006 if targeted_lines.contains(&line) {
1007 return None;
1008 }
1009 landing = line;
1010 crossed += 1;
1011 if indent.len() == target.len() {
1012 break;
1013 }
1014 line += 1;
1015 }
1016 if landing == group.anchor {
1017 None
1018 } else {
1019 Some((landing, crossed))
1020 }
1021}
1022
1023fn retarget_after_anchor(edit: &Edit, line: u32) -> Edit {
1025 match edit {
1026 Edit::Insert {
1027 cursor: _,
1028 text,
1029 line_num,
1030 index,
1031 mode,
1032 } => Edit::Insert {
1033 cursor: Cursor::AfterAnchor(Anchor { line }),
1034 text: text.clone(),
1035 line_num: *line_num,
1036 index: *index,
1037 mode: *mode,
1038 },
1039 other => other.clone(),
1040 }
1041}
1042
1043fn repair_after_insert_landings(edits: &[Edit], file_lines: &[String]) -> (Vec<Edit>, Vec<String>) {
1046 let mut groups: BTreeMap<(u32, u32), AfterInsertGroup> = BTreeMap::new();
1048 for (idx, edit) in edits.iter().enumerate() {
1049 let Edit::Insert {
1050 cursor: Cursor::AfterAnchor(anchor),
1051 line_num,
1052 mode,
1053 ..
1054 } = edit
1055 else {
1056 continue;
1057 };
1058 if *mode == Some(InsertMode::Replacement) {
1059 continue;
1060 }
1061 groups
1062 .entry((anchor.line, *line_num))
1063 .or_insert_with(|| AfterInsertGroup {
1064 anchor: anchor.line,
1065 members: Vec::new(),
1066 })
1067 .members
1068 .push(idx);
1069 }
1070 if groups.is_empty() {
1071 return (edits.to_vec(), Vec::new());
1072 }
1073
1074 let mut targeted_lines: HashSet<u32> = HashSet::new();
1076 for edit in edits {
1077 match edit {
1078 Edit::Delete { anchor, .. } => {
1079 targeted_lines.insert(anchor.line);
1080 }
1081 Edit::Insert {
1082 cursor: Cursor::BeforeAnchor(a) | Cursor::AfterAnchor(a),
1083 ..
1084 } => {
1085 targeted_lines.insert(a.line);
1086 }
1087 _ => {}
1088 }
1089 }
1090
1091 let mut out: Vec<Edit> = edits.to_vec();
1092 let mut warnings = Vec::new();
1093
1094 for group in groups.values() {
1095 let rows: Vec<String> = group
1096 .members
1097 .iter()
1098 .filter_map(|&idx| match &edits[idx] {
1099 Edit::Insert { text, .. } => Some(text.clone()),
1100 _ => None,
1101 })
1102 .collect();
1103 let target = match body_target_indent(&rows) {
1104 Some(t) => t,
1105 None => continue,
1106 };
1107 if let Some((landing, crossed)) =
1108 resolve_shifted_landing(group, target, file_lines, &targeted_lines)
1109 {
1110 for &idx in &group.members {
1111 out[idx] = retarget_after_anchor(&out[idx], landing);
1112 }
1113 warnings.push(after_insert_landing_shift_warning(
1114 group.anchor,
1115 landing,
1116 crossed,
1117 ));
1118 }
1119 }
1120
1121 (out, warnings)
1122}
1123
1124fn trailing_phantom_line(file_lines: &[String]) -> usize {
1130 if file_lines.len() > 1 && file_lines.last().map(|s| s.is_empty()).unwrap_or(false) {
1131 file_lines.len()
1132 } else {
1133 0
1134 }
1135}
1136
1137fn drop_trailing_phantom_deletes(edits: Vec<Edit>, file_lines: &[String]) -> Vec<Edit> {
1140 let phantom = trailing_phantom_line(file_lines);
1141 if phantom == 0 {
1142 return edits;
1143 }
1144 edits
1145 .into_iter()
1146 .filter(
1147 |edit| !matches!(edit, Edit::Delete { anchor, .. } if anchor.line as usize == phantom),
1148 )
1149 .collect()
1150}
1151
1152fn validate_line_bounds(edits: &[Edit], file_lines: &[String]) -> Result<(), HashlineError> {
1154 for edit in edits {
1155 let anchor_line = match edit {
1156 Edit::Delete { anchor, .. } => Some(anchor.line),
1157 Edit::Insert { cursor, .. } => match cursor {
1158 Cursor::BeforeAnchor(a) | Cursor::AfterAnchor(a) => Some(a.line),
1159 Cursor::Bof | Cursor::Eof => None,
1160 },
1161 };
1162 if let Some(line) = anchor_line
1163 && (line < 1 || (line as usize) > file_lines.len())
1164 {
1165 return Err(HashlineError::LineOutOfBounds {
1166 line,
1167 total: file_lines.len(),
1168 });
1169 }
1170 }
1171 Ok(())
1172}
1173
1174fn with_index(edit: &Edit, index: usize) -> Edit {
1176 match edit {
1177 Edit::Insert {
1178 cursor,
1179 text,
1180 line_num,
1181 index: _,
1182 mode,
1183 } => Edit::Insert {
1184 cursor: cursor.clone(),
1185 text: text.clone(),
1186 line_num: *line_num,
1187 index,
1188 mode: *mode,
1189 },
1190 Edit::Delete {
1191 anchor,
1192 line_num,
1193 index: _,
1194 old_assertion,
1195 } => Edit::Delete {
1196 anchor: *anchor,
1197 line_num: *line_num,
1198 index,
1199 old_assertion: old_assertion.clone(),
1200 },
1201 }
1202}
1203
1204fn insert_at_start(file_lines: &mut Vec<String>, lines: &[String]) {
1205 if lines.is_empty() {
1206 return;
1207 }
1208 if file_lines.len() == 1 && file_lines[0].is_empty() {
1209 file_lines.splice(0..1, lines.iter().cloned());
1210 } else {
1211 file_lines.splice(0..0, lines.iter().cloned());
1212 }
1213}
1214
1215fn insert_at_end(file_lines: &mut Vec<String>, lines: &[String]) -> Option<u32> {
1216 if lines.is_empty() {
1217 return None;
1218 }
1219 if file_lines.len() == 1 && file_lines[0].is_empty() {
1220 file_lines.splice(0..1, lines.iter().cloned());
1221 return Some(1);
1222 }
1223 let has_trailing_newline = file_lines.last().map(|s| s.is_empty()).unwrap_or(false);
1224 let insert_index = if has_trailing_newline {
1225 file_lines.len() - 1
1226 } else {
1227 file_lines.len()
1228 };
1229 file_lines.splice(insert_index..insert_index, lines.iter().cloned());
1230 Some(insert_index as u32 + 1)
1231}
1232
1233pub fn apply_edits(text: &str, edits: &[Edit]) -> Result<ApplyResult, HashlineError> {
1242 if edits.is_empty() {
1243 return Ok(ApplyResult {
1244 text: text.to_string(),
1245 first_changed_line: None,
1246 warnings: Vec::new(),
1247 });
1248 }
1249
1250 let mut file_lines: Vec<String> = text.split('\n').map(String::from).collect();
1251 let mut first_changed_line: Option<u32> = None;
1252 let track = |current: &mut Option<u32>, line: u32| match current {
1253 None => *current = Some(line),
1254 Some(existing) if line < *existing => *current = Some(line),
1255 _ => {}
1256 };
1257
1258 let cloned: Vec<Edit> = edits
1259 .iter()
1260 .enumerate()
1261 .map(|(i, e)| with_index(e, i))
1262 .collect();
1263 let target_edits = drop_trailing_phantom_deletes(cloned, &file_lines);
1264 validate_line_bounds(&target_edits, &file_lines)?;
1265
1266 let (repaired, boundary_warnings) = repair_replacement_boundaries(target_edits, &file_lines);
1267 let (landed, landing_warnings) = repair_after_insert_landings(&repaired, &file_lines);
1268
1269 let mut warnings = boundary_warnings;
1270 warnings.extend(landing_warnings);
1271
1272 let mut bof_lines: Vec<String> = Vec::new();
1274 let mut eof_lines: Vec<String> = Vec::new();
1275 let mut anchor_edits: Vec<(usize, &Edit)> = Vec::new();
1276 for (idx, edit) in landed.iter().enumerate() {
1277 match edit {
1278 Edit::Insert {
1279 cursor: Cursor::Bof,
1280 text,
1281 ..
1282 } => bof_lines.push(text.clone()),
1283 Edit::Insert {
1284 cursor: Cursor::Eof,
1285 text,
1286 ..
1287 } => eof_lines.push(text.clone()),
1288 _ => anchor_edits.push((idx, edit)),
1289 }
1290 }
1291
1292 let mut by_line: BTreeMap<u32, Vec<(usize, &Edit)>> = BTreeMap::new();
1294 for &(idx, edit) in &anchor_edits {
1295 by_line
1296 .entry(edit.anchor_line())
1297 .or_default()
1298 .push((idx, edit));
1299 }
1300
1301 for line in by_line.keys().copied().rev().collect::<Vec<_>>() {
1302 let mut bucket = by_line.remove(&line).unwrap_or_default();
1303 bucket.sort_by_key(|(idx, _)| *idx);
1304
1305 let idx = (line - 1) as usize;
1306 let current_line = file_lines.get(idx).cloned().unwrap_or_default();
1307 let mut before_insert: Vec<String> = Vec::new();
1308 let mut after_insert: Vec<String> = Vec::new();
1309 let mut replacement: Vec<String> = Vec::new();
1310 let mut delete_line = false;
1311
1312 for (_, edit) in &bucket {
1313 match edit {
1314 Edit::Insert {
1315 mode: Some(InsertMode::Replacement),
1316 text,
1317 ..
1318 } => replacement.push(text.clone()),
1319 Edit::Insert {
1320 cursor: Cursor::AfterAnchor(_),
1321 text,
1322 ..
1323 } => after_insert.push(text.clone()),
1324 Edit::Insert { text, .. } => before_insert.push(text.clone()),
1325 Edit::Delete { .. } => delete_line = true,
1326 }
1327 }
1328
1329 if before_insert.is_empty()
1330 && replacement.is_empty()
1331 && after_insert.is_empty()
1332 && !delete_line
1333 {
1334 continue;
1335 }
1336
1337 let mut new_lines = before_insert;
1338 new_lines.extend(replacement);
1339 if !delete_line {
1340 new_lines.push(current_line);
1341 }
1342 new_lines.extend(after_insert);
1343
1344 file_lines.splice(idx..=idx, new_lines);
1345 track(&mut first_changed_line, line);
1346 }
1347
1348 if !bof_lines.is_empty() {
1349 insert_at_start(&mut file_lines, &bof_lines);
1350 track(&mut first_changed_line, 1);
1351 }
1352 if let Some(eof_line) = insert_at_end(&mut file_lines, &eof_lines) {
1353 track(&mut first_changed_line, eof_line);
1354 }
1355
1356 Ok(ApplyResult {
1357 text: file_lines.join("\n"),
1358 first_changed_line,
1359 warnings,
1360 })
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365 use super::*;
1366
1367 fn ins_before(line: u32, text: &str) -> Edit {
1370 Edit::Insert {
1371 cursor: Cursor::BeforeAnchor(Anchor { line }),
1372 text: text.to_string(),
1373 line_num: 1,
1374 index: 0,
1375 mode: None,
1376 }
1377 }
1378
1379 fn ins_after(line: u32, text: &str) -> Edit {
1380 Edit::Insert {
1381 cursor: Cursor::AfterAnchor(Anchor { line }),
1382 text: text.to_string(),
1383 line_num: 1,
1384 index: 0,
1385 mode: None,
1386 }
1387 }
1388
1389 fn ins_head(text: &str) -> Edit {
1390 Edit::Insert {
1391 cursor: Cursor::Bof,
1392 text: text.to_string(),
1393 line_num: 1,
1394 index: 0,
1395 mode: None,
1396 }
1397 }
1398
1399 fn ins_tail(text: &str) -> Edit {
1400 Edit::Insert {
1401 cursor: Cursor::Eof,
1402 text: text.to_string(),
1403 line_num: 1,
1404 index: 0,
1405 mode: None,
1406 }
1407 }
1408
1409 fn del(line: u32) -> Edit {
1410 Edit::Delete {
1411 anchor: Anchor { line },
1412 line_num: 1,
1413 index: 0,
1414 old_assertion: None,
1415 }
1416 }
1417
1418 fn swap(start: u32, end: u32, body: &[&str]) -> Vec<Edit> {
1421 let mut edits = Vec::new();
1422 for (i, text) in body.iter().enumerate() {
1423 edits.push(Edit::Insert {
1424 cursor: Cursor::BeforeAnchor(Anchor { line: start }),
1425 text: text.to_string(),
1426 line_num: 1,
1427 index: i,
1428 mode: Some(InsertMode::Replacement),
1429 });
1430 }
1431 for (i, line) in (start..=end).enumerate() {
1432 edits.push(Edit::Delete {
1433 anchor: Anchor { line },
1434 line_num: 1,
1435 index: body.len() + i,
1436 old_assertion: None,
1437 });
1438 }
1439 edits
1440 }
1441
1442 #[test]
1445 fn basic_replace() {
1446 let edits = swap(2, 3, &["B", "C"]);
1447 let result = apply_edits("a\nb\nc\nd", &edits).unwrap();
1448 assert_eq!(result.text, "a\nB\nC\nd");
1449 assert_eq!(result.first_changed_line, Some(2));
1450 assert!(result.warnings.is_empty());
1451 }
1452
1453 #[test]
1454 fn basic_replace_single_line() {
1455 let edits = swap(2, 2, &["X"]);
1456 let result = apply_edits("a\nb\nc", &edits).unwrap();
1457 assert_eq!(result.text, "a\nX\nc");
1458 }
1459
1460 #[test]
1461 fn basic_insert_before() {
1462 let edits = vec![ins_before(2, "X")];
1463 let result = apply_edits("a\nb\nc", &edits).unwrap();
1464 assert_eq!(result.text, "a\nX\nb\nc");
1465 }
1466
1467 #[test]
1468 fn basic_insert_after() {
1469 let edits = vec![ins_after(2, "X")];
1470 let result = apply_edits("a\nb\nc", &edits).unwrap();
1471 assert_eq!(result.text, "a\nb\nX\nc");
1472 }
1473
1474 #[test]
1475 fn basic_insert_head() {
1476 let edits = vec![ins_head("X")];
1477 let result = apply_edits("a\nb\nc", &edits).unwrap();
1478 assert_eq!(result.text, "X\na\nb\nc");
1479 assert_eq!(result.first_changed_line, Some(1));
1480 }
1481
1482 #[test]
1483 fn basic_insert_tail() {
1484 let edits = vec![ins_tail("X")];
1485 let result = apply_edits("a\nb\nc", &edits).unwrap();
1486 assert_eq!(result.text, "a\nb\nc\nX");
1487 assert_eq!(result.first_changed_line, Some(4));
1488 }
1489
1490 #[test]
1491 fn basic_delete_single() {
1492 let edits = vec![del(2)];
1493 let result = apply_edits("a\nb\nc", &edits).unwrap();
1494 assert_eq!(result.text, "a\nc");
1495 }
1496
1497 #[test]
1498 fn basic_delete_range() {
1499 let edits = vec![del(2), del(3)];
1500 let result = apply_edits("a\nb\nc\nd", &edits).unwrap();
1501 assert_eq!(result.text, "a\nd");
1502 }
1503
1504 #[test]
1505 fn empty_edits_noop() {
1506 let result = apply_edits("a\nb", &[]).unwrap();
1507 assert_eq!(result.text, "a\nb");
1508 assert_eq!(result.first_changed_line, None);
1509 }
1510
1511 #[test]
1512 fn insert_into_empty_file() {
1513 let edits = vec![ins_head("X")];
1514 let result = apply_edits("", &edits).unwrap();
1515 assert_eq!(result.text, "X");
1516 }
1517
1518 #[test]
1521 fn trailing_phantom_delete_dropped() {
1522 let edits = vec![del(2), del(3)];
1525 let result = apply_edits("a\nb\n", &edits).unwrap();
1526 assert_eq!(result.text, "a\n");
1527 }
1528
1529 #[test]
1530 fn no_phantom_when_no_trailing_newline() {
1531 let edits = vec![del(2)];
1532 let result = apply_edits("a\nb", &edits).unwrap();
1533 assert_eq!(result.text, "a");
1534 }
1535
1536 #[test]
1537 fn insert_tail_preserves_trailing_newline() {
1538 let edits = vec![ins_tail("X")];
1539 let result = apply_edits("a\nb\n", &edits).unwrap();
1540 assert_eq!(result.text, "a\nb\nX\n");
1541 }
1542
1543 #[test]
1546 fn line_out_of_bounds() {
1547 let edits = vec![ins_before(99, "X")];
1548 let result = apply_edits("a\nb", &edits);
1549 assert!(result.is_err());
1550 match result {
1551 Err(HashlineError::LineOutOfBounds { line, total }) => {
1552 assert_eq!(line, 99);
1553 assert_eq!(total, 2);
1554 }
1555 _ => panic!("expected LineOutOfBounds"),
1556 }
1557 }
1558
1559 #[test]
1560 fn bof_eof_not_validated_against_bounds() {
1561 let edits = vec![ins_head("X"), ins_tail("Y")];
1562 let result = apply_edits("a", &edits).unwrap();
1563 assert_eq!(result.text, "X\na\nY");
1564 }
1565
1566 #[test]
1569 fn delimiter_balance_simple() {
1570 assert_eq!(
1571 compute_delimiter_balance(&["foo(bar)".to_string()]),
1572 DelimiterBalance::default()
1573 );
1574 assert_eq!(
1575 compute_delimiter_balance(&["foo(bar".to_string()]),
1576 DelimiterBalance {
1577 paren: 1,
1578 bracket: 0,
1579 brace: 0
1580 }
1581 );
1582 }
1583
1584 #[test]
1585 fn delimiter_balance_skips_string_literals() {
1586 let lines = vec!["x = \"(\"".to_string()];
1587 assert_eq!(
1588 compute_delimiter_balance(&lines),
1589 DelimiterBalance::default()
1590 );
1591 }
1592
1593 #[test]
1594 fn delimiter_balance_skips_single_quotes() {
1595 let lines = vec!["y = ']'".to_string()];
1596 assert_eq!(
1597 compute_delimiter_balance(&lines),
1598 DelimiterBalance::default()
1599 );
1600 }
1601
1602 #[test]
1603 fn delimiter_balance_skips_line_comments() {
1604 let lines = vec!["// ({[".to_string()];
1605 assert_eq!(
1606 compute_delimiter_balance(&lines),
1607 DelimiterBalance::default()
1608 );
1609 }
1610
1611 #[test]
1612 fn delimiter_balance_block_comment_spans_lines() {
1613 let lines = vec!["/* (".to_string(), "[ */ )".to_string()];
1614 let bal = compute_delimiter_balance(&lines);
1615 assert_eq!(bal.paren, -1); assert_eq!(bal.bracket, 0); }
1618
1619 #[test]
1620 fn delimiter_balance_template_spans_lines() {
1621 let lines = vec!["`(".to_string(), ")`".to_string()];
1622 assert_eq!(
1623 compute_delimiter_balance(&lines),
1624 DelimiterBalance::default()
1625 );
1626 }
1627
1628 #[test]
1629 fn delimiter_balance_single_quote_resets_at_eol() {
1630 let lines = vec!["(x = '".to_string(), " bar".to_string()];
1632 let bal = compute_delimiter_balance(&lines);
1633 assert_eq!(bal.paren, 1);
1634 }
1635
1636 #[test]
1637 fn delimiter_balance_mixed() {
1638 let lines = vec![
1639 "function f() {".to_string(),
1640 " return [1, 2];".to_string(),
1641 "}".to_string(),
1642 ];
1643 let bal = compute_delimiter_balance(&lines);
1644 assert_eq!(bal, DelimiterBalance::default());
1645 }
1646
1647 #[test]
1650 fn bracket_closer_lines() {
1651 assert!(is_bracket_closer_line("}"));
1652 assert!(is_bracket_closer_line("});"));
1653 assert!(is_bracket_closer_line("})"));
1654 assert!(is_bracket_closer_line("];"));
1655 assert!(is_bracket_closer_line(" }"));
1656 assert!(!is_bracket_closer_line("} ,"));
1657 assert!(!is_bracket_closer_line("x = 1"));
1658 assert!(!is_bracket_closer_line("return }"));
1659 assert!(!is_bracket_closer_line(""));
1660 }
1661
1662 #[test]
1663 fn jsx_closer_lines() {
1664 assert!(is_jsx_closer_line("</div>"));
1665 assert!(is_jsx_closer_line("</>"));
1666 assert!(is_jsx_closer_line("/>"));
1667 assert!(is_jsx_closer_line(" </Section>"));
1668 assert!(is_jsx_closer_line("</div>;"));
1669 assert!(!is_jsx_closer_line("<div>"));
1670 assert!(!is_jsx_closer_line("x = 1"));
1671 }
1672
1673 #[test]
1674 fn structural_closer_includes_both() {
1675 assert!(is_structural_closer_line("}"));
1676 assert!(is_structural_closer_line("</div>"));
1677 }
1678
1679 #[test]
1680 fn jsx_closer_name_extraction() {
1681 assert_eq!(jsx_closer_name("</div>"), Some("div".to_string()));
1682 assert_eq!(jsx_closer_name("</>"), Some(String::new()));
1683 assert_eq!(jsx_closer_name("x = 1"), None);
1684 }
1685
1686 #[test]
1689 fn boundary_echo_drops_both_sides() {
1690 let edits = swap(2, 3, &["a", "B", "C", "d"]);
1693 let result = apply_edits("a\nb\nc\nd\ne", &edits).unwrap();
1694 assert_eq!(result.text, "a\nB\nC\nd\ne");
1695 assert_eq!(result.warnings.len(), 1);
1696 assert!(result.warnings[0].contains("boundary echo"));
1697 }
1698
1699 #[test]
1700 fn boundary_echo_with_brackets() {
1701 let file = "function foo() {\n return 1;\n}";
1703 let edits = swap(2, 2, &["function foo() {", " return 2;", "}"]);
1705 let result = apply_edits(file, &edits).unwrap();
1706 assert_eq!(result.text, "function foo() {\n return 2;\n}");
1707 assert_eq!(result.warnings.len(), 1);
1708 }
1709
1710 #[test]
1711 fn no_boundary_echo_when_payload_too_short() {
1712 let edits = swap(2, 2, &["a", "c"]);
1716 let result = apply_edits("a\nb\nc", &edits).unwrap();
1717 assert_eq!(result.text, "a\na\nc\nc");
1718 assert!(result.warnings.is_empty());
1719 }
1720
1721 #[test]
1724 fn one_sided_echo_trailing_structural_closer() {
1725 let edits = swap(1, 1, &["X", "</div>"]);
1734 let result = apply_edits("x\n</div>", &edits).unwrap();
1735 assert_eq!(result.text, "X\n</div>");
1736 assert_eq!(result.warnings.len(), 1);
1737 assert!(result.warnings[0].contains("one line short"));
1738 }
1739
1740 #[test]
1741 fn one_sided_echo_leading_multi_line() {
1742 let file = "a\nb\nc\nd";
1744 let edits = swap(2, 3, &["a", "B", "C"]);
1750 let result = apply_edits(file, &edits).unwrap();
1751 assert_eq!(result.text, "a\nB\nC\nd");
1752 assert_eq!(result.warnings.len(), 1);
1753 assert!(result.warnings[0].contains("leading"));
1754 }
1755
1756 #[test]
1759 fn duplicate_suffix_dropped() {
1760 let file = "foo(\n bar\n)\n)";
1763 let edits = swap(1, 3, &["new(", " content", ")", ")"]);
1764 let result = apply_edits(file, &edits).unwrap();
1765 assert_eq!(result.text, "new(\n content\n)\n)");
1766 assert_eq!(result.warnings.len(), 1);
1767 assert!(result.warnings[0].contains("trailing"));
1768 }
1769
1770 #[test]
1771 fn duplicate_prefix_dropped() {
1772 let file = "(\nx\n)";
1777 let edits = swap(2, 2, &["(", "Y"]);
1778 let result = apply_edits(file, &edits).unwrap();
1779 assert_eq!(result.text, "(\nY\n)");
1780 assert_eq!(result.warnings.len(), 1);
1781 assert!(result.warnings[0].contains("leading"));
1782 }
1783
1784 #[test]
1787 fn dropped_suffix_closer_preserved() {
1788 let file = "x\n}\ny";
1795 let edits = swap(1, 2, &["X"]);
1796 let result = apply_edits(file, &edits).unwrap();
1797 assert_eq!(result.text, "X\n}\ny");
1798 assert_eq!(result.warnings.len(), 1);
1799 assert!(result.warnings[0].contains("kept 1"));
1800 }
1801
1802 #[test]
1805 fn landing_shift_outward() {
1806 let file = "function foo() {\n bar();\n}";
1808 let edits = vec![ins_after(2, "baz();")];
1809 let result = apply_edits(file, &edits).unwrap();
1810 assert_eq!(result.text, "function foo() {\n bar();\n}\nbaz();");
1812 assert_eq!(result.warnings.len(), 1);
1813 assert!(result.warnings[0].contains("INS.POST 2"));
1814 assert!(result.warnings[0].contains("after line 3"));
1815 }
1816
1817 #[test]
1818 fn landing_no_shift_when_indent_matches() {
1819 let file = "function foo() {\n bar();\n}";
1821 let edits = vec![ins_after(2, " baz();")];
1822 let result = apply_edits(file, &edits).unwrap();
1823 assert_eq!(result.text, "function foo() {\n bar();\n baz();\n}");
1824 assert!(result.warnings.is_empty());
1825 }
1826
1827 #[test]
1828 fn landing_no_shift_for_content_line_after_anchor() {
1829 let file = "a\nb\nc";
1831 let edits = vec![ins_after(1, "X")];
1832 let result = apply_edits(file, &edits).unwrap();
1833 assert_eq!(result.text, "a\nX\nb\nc");
1834 assert!(result.warnings.is_empty());
1835 }
1836
1837 #[test]
1838 fn landing_abandoned_when_targeted_line_crossed() {
1839 let file = "function foo() {\n bar();\n}";
1841 let edits = vec![ins_after(2, "baz();"), del(3)];
1843 let result = apply_edits(file, &edits).unwrap();
1844 assert_eq!(result.text, "function foo() {\n bar();\nbaz();");
1846 assert!(result.warnings.is_empty());
1847 }
1848
1849 #[test]
1852 fn leading_indent_extraction() {
1853 assert_eq!(leading_indent(" foo"), " ");
1854 assert_eq!(leading_indent("\t\tbar"), "\t\t");
1855 assert_eq!(leading_indent("foo"), "");
1856 assert_eq!(leading_indent(" "), " ");
1857 }
1858
1859 #[test]
1860 fn indent_deeper_check() {
1861 assert!(is_indent_deeper(" ", " "));
1862 assert!(is_indent_deeper(" ", ""));
1863 assert!(!is_indent_deeper(" ", " "));
1864 assert!(!is_indent_deeper(" ", " "));
1865 assert!(!is_indent_deeper("\t", " ")); }
1867
1868 #[test]
1869 fn body_target_indent_computation() {
1870 let rows = vec![" a".to_string(), " b".to_string()];
1871 assert_eq!(body_target_indent(&rows), Some(" "));
1872
1873 let rows = vec![" a".to_string(), "b".to_string()];
1874 assert_eq!(body_target_indent(&rows), Some(""));
1875
1876 let rows = vec![" a".to_string(), "\tb".to_string()];
1877 assert_eq!(body_target_indent(&rows), None); let rows = vec!["}".to_string()];
1880 assert_eq!(body_target_indent(&rows), None); let rows = vec!["".to_string()];
1883 assert_eq!(body_target_indent(&rows), None); }
1885
1886 #[test]
1889 fn trailing_phantom_detection() {
1890 assert_eq!(
1891 trailing_phantom_line(&["a".to_string(), "b".to_string(), "".to_string()]),
1892 3
1893 );
1894 assert_eq!(
1895 trailing_phantom_line(&["a".to_string(), "b".to_string()]),
1896 0
1897 );
1898 assert_eq!(trailing_phantom_line(&["".to_string()]), 0); }
1900
1901 #[test]
1904 fn multiple_edits_same_file() {
1905 let edits = vec![ins_before(1, "header"), ins_after(2, "tail_of_2"), del(3)];
1906 let result = apply_edits("a\nb\nc\nd", &edits).unwrap();
1907 assert_eq!(result.text, "header\na\nb\ntail_of_2\nd");
1908 }
1909
1910 #[test]
1911 fn first_changed_line_tracks_minimum() {
1912 let edits = vec![ins_head("X"), del(3)];
1914 let result = apply_edits("a\nb\nc", &edits).unwrap();
1915 assert_eq!(result.text, "X\na\nb");
1916 assert_eq!(result.first_changed_line, Some(1));
1917 }
1918
1919 #[test]
1920 fn replacement_preserves_unaffected_lines() {
1921 let file = "line1\nline2\nline3\nline4\nline5";
1922 let edits = swap(2, 4, &["new2", "new3", "new4"]);
1923 let result = apply_edits(file, &edits).unwrap();
1924 assert_eq!(result.text, "line1\nnew2\nnew3\nnew4\nline5");
1925 }
1926
1927 #[test]
1928 fn has_non_whitespace_check() {
1929 assert!(has_non_whitespace(" x "));
1930 assert!(!has_non_whitespace(" "));
1931 assert!(!has_non_whitespace("\t\n\r"));
1932 assert!(has_non_whitespace("a"));
1933 }
1934}