Skip to main content

oxicode_hashline/
apply.rs

1//! Apply a parsed list of [`Edit`]s to a text body and return the post-edit
2//! text plus diagnostic warnings.
3//!
4//! Replacement groups are first normalized by boundary repair, which absorbs
5//! common model mistakes where a payload restates unchanged range boundaries
6//! or duplicates/drops structural closers. After-insert landings are then
7//! corrected when a body's indentation claims a depth different from its
8//! anchor's.
9//!
10//! Ported from omp `packages/hashline/src/apply.ts`.
11
12use crate::mismatch::HashlineError;
13use crate::types::{Anchor, ApplyResult, Cursor, Edit, InsertMode};
14use std::collections::{BTreeMap, HashSet};
15
16// ═══════════════════════════════════════════════════════════════════════════
17// Delimiter balance
18// ═══════════════════════════════════════════════════════════════════════════
19
20/// Net `()` / `[]` / `{}` delta across a set of lines.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22struct DelimiterBalance {
23    paren: i32,
24    bracket: i32,
25    brace: i32,
26}
27
28/// Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
29/// comments (`//`), block comments, and string/template literals. Block-comment
30/// and backtick-template state carry across lines; `"` / `'` reset at EOL since
31/// they cannot span lines.
32///
33/// Byte-scanned: all relevant delimiter characters are ASCII, and UTF-8
34/// guarantees multi-byte continuation bytes are ≥ 0x80, so they never collide
35/// with the ASCII bytes we test.
36fn 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; // skip backslash + escaped char
57                } 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, // line comment
71                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        // `"` / `'` cannot span lines; only backtick templates and block comments do.
103        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
130// ═══════════════════════════════════════════════════════════════════════════
131// Closer detection
132// ═══════════════════════════════════════════════════════════════════════════
133
134/// Matches omp `STRUCTURAL_CLOSER_RE`: `^\s*[)\]}]+[;,]?\s*$` — a line of
135/// nothing but closing brackets, optionally terminated by `;` or `,`.
136fn 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
159/// A byte matching `[\w.:-]` (JS without `u` flag): ASCII alphanumeric, `_`,
160/// `.`, `:`, `-`.
161fn is_jsx_name_byte(b: u8) -> bool {
162    b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b':' || b == b'-'
163}
164
165/// `[A-Za-z][\w.:-]*`
166fn 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
174/// Matches omp `JSX_CLOSER_RE`: `^\s*(?:<\/>|<\/Name>|\/>)\s*[;,]?\s*$`.
175fn 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
188/// A structural closer: bracket closers or JSX closers.
189fn is_structural_closer_line(text: &str) -> bool {
190    is_bracket_closer_line(text) || is_jsx_closer_line(text)
191}
192
193/// omp `jsxCloserName`: `Some("")` for `</>`, `Some("Name")` for `</Name>`,
194/// `None` otherwise.
195fn 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
209/// Remove an optional trailing `;` or `,` (with any whitespace before it)
210/// from an already-trimmed string.
211fn 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// ═══════════════════════════════════════════════════════════════════════════
220// JSX tag parsing (for single-line one-sided echo guard)
221// ═══════════════════════════════════════════════════════════════════════════
222
223#[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
237/// Find the `>` that closes the JSX tag starting at `start`, respecting
238/// string literals and `{...}` expression braces. Returns a byte index or
239/// `None`.
240fn 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; // skip escaped char
250            } 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
325/// Whether the payload prefix opens a JSX tag that one of the echo closers
326/// would close.
327fn 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
344// ═══════════════════════════════════════════════════════════════════════════
345// Replacement group detection
346// ═══════════════════════════════════════════════════════════════════════════
347
348/// A run of replacement-mode inserts sharing one source op line, immediately
349/// followed by the contiguous range deletes for that same op.
350struct ReplacementGroup {
351    /// Positions in the edit array of the payload inserts, in payload order.
352    insert_indices: Vec<usize>,
353    /// Positions in the edit array of the range deletes, ascending by line.
354    delete_indices: Vec<usize>,
355    payload: Vec<String>,
356    /// First deleted line (1-indexed).
357    start_line: u32,
358    /// Last deleted line (1-indexed).
359    end_line: u32,
360}
361
362/// Detect a replacement group starting at `start`.
363fn 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
428// ═══════════════════════════════════════════════════════════════════════════
429// Boundary echo detection
430// ═══════════════════════════════════════════════════════════════════════════
431
432fn 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
437/// Largest `count` such that the payload's first `count` lines exactly equal
438/// the `count` surviving file lines just above the range, with at least one
439/// non-whitespace line among them.
440fn 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
467/// Largest `count` such that the payload's last `count` lines exactly equal
468/// the `count` surviving file lines just below the range, with at least one
469/// non-whitespace line among them.
470fn 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
503/// Two-sided boundary echo: the payload restates unchanged lines on BOTH sides
504/// of the range. Balance-neutral unless the dropped echo exactly explains the
505/// payload/range delta.
506fn 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// ═══════════════════════════════════════════════════════════════════════════
542// One-sided boundary echo
543// ═══════════════════════════════════════════════════════════════════════════
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546enum EchoSide {
547    Leading,
548    Trailing,
549}
550
551struct OneSidedEcho {
552    side: EchoSide,
553    count: usize,
554}
555
556/// A single-sided boundary echo in an otherwise delimiter-balanced multi-line
557/// replacement: the payload's leading XOR trailing edge restates surviving
558/// line(s) just outside the range. Single-line ranges are only repaired when
559/// the edge is a trailing structural closer.
560fn 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
599// ═══════════════════════════════════════════════════════════════════════════
600// Duplicate / dropped-closer detection (delimiter-imbalanced groups)
601// ═══════════════════════════════════════════════════════════════════════════
602
603/// Largest `k` such that the payload's last `k` lines equal the surviving file
604/// lines just below the range AND dropping them zeroes `delta`.
605fn 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
636/// Largest `j` such that the payload's first `j` lines equal the surviving file
637/// lines just above the range AND dropping them zeroes `delta`.
638fn 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
686/// Smallest `m` such that the range's last `m` deleted lines are all structural
687/// closers, the payload does not already restate them, and sparing them zeroes
688/// `delta`.
689fn 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
713// ═══════════════════════════════════════════════════════════════════════════
714// Warning messages
715// ═══════════════════════════════════════════════════════════════════════════
716
717fn 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
777// ═══════════════════════════════════════════════════════════════════════════
778// Boundary repair
779// ═══════════════════════════════════════════════════════════════════════════
780
781/// Normalize replacement groups so common off-by-one boundaries do not
782/// duplicate unchanged surrounding lines or structural closers.
783fn 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        // 1. Two-sided boundary echo
813        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            // 2. One-sided echo (balance-neutral)
833            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        // 3. Duplicate suffix (trailing edge restates a closer/opener below)
864        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        // 4. Duplicate prefix (leading edge restates a closer/opener above)
878        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        // 5. Dropped suffix closers (range swallowed a closer the payload never restated)
892        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
913// ═══════════════════════════════════════════════════════════════════════════
914// After-insert landing correction
915// ═══════════════════════════════════════════════════════════════════════════
916
917/// Leading run of tabs and spaces (byte length).
918fn 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
926/// `deeper` strictly extends `shallower` (same indent style, more depth).
927fn is_indent_deeper(deeper: &str, shallower: &str) -> bool {
928    deeper.len() > shallower.len() && deeper.starts_with(shallower)
929}
930
931/// An after-insert hunk: rows sharing one anchor line and one patch header
932/// line.
933struct AfterInsertGroup {
934    anchor: u32,
935    members: Vec<usize>,
936}
937
938/// Shallowest indentation across non-blank body rows, or `None` when no depth
939/// claim can be made (all-blank, all-closer, or incomparable indent styles).
940fn 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
968/// Resolve where an after-insert hunk should land when its body is shallower
969/// than the anchor: slide forward past structural closer lines whose
970/// indentation still covers the body's target depth.
971fn 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
1023/// Re-target an insert's cursor to `AfterAnchor(line)`.
1024fn 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
1043/// Slide mis-anchored after-insert hunks outward to the depth their body
1044/// indentation claims.
1045fn repair_after_insert_landings(edits: &[Edit], file_lines: &[String]) -> (Vec<Edit>, Vec<String>) {
1046    // Group plain (non-replacement) after-anchor inserts per authored hunk.
1047    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    // Lines explicitly targeted by any edit; a shift never crosses them.
1075    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
1124// ═══════════════════════════════════════════════════════════════════════════
1125// Bucket / apply helpers
1126// ═══════════════════════════════════════════════════════════════════════════
1127
1128/// Index of the trailing phantom sentinel line (0 if none).
1129fn 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
1137/// Drop delete edits that target the trailing phantom line — deleting it only
1138/// strips the file's final newline.
1139fn 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
1152/// Verify every anchored edit points at an existing line.
1153fn 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
1174/// Clone an edit with a new sequential `index`.
1175fn 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
1233// ═══════════════════════════════════════════════════════════════════════════
1234// Main entry point
1235// ═══════════════════════════════════════════════════════════════════════════
1236
1237/// Apply a parsed list of [`Edit`]s to `text`. Pure function — no I/O.
1238///
1239/// Returns the post-edit text, the first changed line number (1-indexed), and
1240/// any diagnostic warnings from boundary repair / landing correction.
1241pub 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    // Partition into bof, eof, and anchor-targeted buckets.
1273    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    // Bucket anchor edits by line, then apply bottom-up.
1293    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    // ── Edit constructors ────────────────────────────────────────────────
1368
1369    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    /// Lower a SWAP start.=end: to replacement inserts (BeforeAnchor) + range
1419    /// deletes, matching the parser's lowering.
1420    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    // ── Basic operations ────────────────────────────────────────────────
1443
1444    #[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    // ── Trailing phantom line ────────────────────────────────────────────
1519
1520    #[test]
1521    fn trailing_phantom_delete_dropped() {
1522        // "a\nb\n" → ["a", "b", ""], phantom at line 3.
1523        // DEL 2.=3 should only delete line 2, preserving the final newline.
1524        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    // ── Line bounds validation ──────────────────────────────────────────
1544
1545    #[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    // ── Delimiter balance ───────────────────────────────────────────────
1567
1568    #[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); // the ) after the comment closes nothing
1616        assert_eq!(bal.bracket, 0); // the [ is inside the comment
1617    }
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        // `"` / `'` cannot span lines; the open paren before the quote counts.
1631        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    // ── Closer detection ────────────────────────────────────────────────
1648
1649    #[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    // ── Boundary echo: two-sided ────────────────────────────────────────
1687
1688    #[test]
1689    fn boundary_echo_drops_both_sides() {
1690        // File: a b c d e
1691        // SWAP 2.=3: payload restates line 1 (leading) and line 4 (trailing).
1692        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        // Function body replacement where the model restates header + closer.
1702        let file = "function foo() {\n  return 1;\n}";
1703        // Payload restates header and closer (balance-neutral check).
1704        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        // Both sides echo but their sum covers the whole payload → bail.
1713        // SWAP 2.=2 on "a\nb\nc" with payload ["a", "c"]: "a" echoes line 1,
1714        // "c" echoes line 3. leading+trailing = 2 >= payload.len() = 2 → bail.
1715        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    // ── One-sided echo ──────────────────────────────────────────────────
1722
1723    #[test]
1724    fn one_sided_echo_trailing_structural_closer() {
1725        // Single-line replacement, trailing edge restates a JSX structural
1726        // closer below the range, delta is zero, echo line is balance-neutral.
1727        // File: "x\n</div>"
1728        // SWAP 1.=1 with payload ["X", "</div>"] — the model retyped the closer.
1729        // delta = payload_balance - deleted_balance = 0 - 0 = 0.
1730        // Trailing: payload[1] = "</div>" == fileLines[1] = "</div>". Count 1.
1731        // Leading: 0. XOR satisfied. Single-line range, trailing, structural
1732        // closer with zero delimiter balance, no JSX opener in prefix.
1733        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        // Multi-line range, leading edge restates a line above, balance is zero.
1743        let file = "a\nb\nc\nd";
1744        // SWAP 2.=3 replaces "b" and "c" with "a\nB\nC".
1745        // Leading: payload[0]="a" == fileLines[0]. Count 1. Content.
1746        // Trailing: payload[2]="C" vs fileLines[3]="d". No match. Count 0.
1747        // XOR: leading>0, trailing=0. Multi-line range (2 deletes).
1748        // Echo balance ["a"] = 0. Pass.
1749        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    // ── Duplicate suffix / prefix (delimiter-imbalanced) ────────────────
1757
1758    #[test]
1759    fn duplicate_suffix_dropped() {
1760        // File: foo( \n bar \n ) \n )
1761        // SWAP 1.=3 replaces with payload that restates the trailing ")".
1762        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        // File: "(\nx\n)" — SWAP 2.=2 replaces "x" with "(\nY".
1773        // The model restated "(" (line 1) at the start of the payload.
1774        // deleted "x" → balance 0. payload "(\nY" → paren 1.
1775        // delta = 1 - 0 = paren 1. prefix "(" balance = paren 1 = delta. Match!
1776        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    // ── Dropped suffix closers ──────────────────────────────────────────
1785
1786    #[test]
1787    fn dropped_suffix_closer_preserved() {
1788        // File: x \n } \n y
1789        // SWAP 1.=2 replaces "x" and "}" with "X" (forgot the closer).
1790        // delta = payload_balance - deleted_balance = 0 - (-1) = brace 1.
1791        // wanted = negate(delta) = brace -1.
1792        // m=1: fileLines[endLine-1] = fileLines[1] = "}". Closer.
1793        // payload doesn't end with deleted suffix. suffix "}" balance = -1 == wanted. Match!
1794        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    // ── Landing correction ──────────────────────────────────────────────
1803
1804    #[test]
1805    fn landing_shift_outward() {
1806        // Body indented shallower than the anchor → slide past closer lines.
1807        let file = "function foo() {\n  bar();\n}";
1808        let edits = vec![ins_after(2, "baz();")];
1809        let result = apply_edits(file, &edits).unwrap();
1810        // baz(); (no indent) should land after line 3 "}", not after line 2.
1811        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        // Body at same indent as anchor → no shift.
1820        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        // A content (non-closer) line follows the anchor → no crossing.
1830        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        // Another edit targets the closer → shift abandoned.
1840        let file = "function foo() {\n  bar();\n}";
1841        // Delete line 3 (the closer) + insert after line 2 at depth 0.
1842        let edits = vec![ins_after(2, "baz();"), del(3)];
1843        let result = apply_edits(file, &edits).unwrap();
1844        // The closer is deleted, so the insert stays at line 2.
1845        assert_eq!(result.text, "function foo() {\n  bar();\nbaz();");
1846        assert!(result.warnings.is_empty());
1847    }
1848
1849    // ── Indent helpers ──────────────────────────────────────────────────
1850
1851    #[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", "  ")); // tab vs space — not a prefix
1866    }
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); // incomparable
1878
1879        let rows = vec!["}".to_string()];
1880        assert_eq!(body_target_indent(&rows), None); // all closers
1881
1882        let rows = vec!["".to_string()];
1883        assert_eq!(body_target_indent(&rows), None); // all blank
1884    }
1885
1886    // ── Phantom detection ───────────────────────────────────────────────
1887
1888    #[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); // single empty line
1899    }
1900
1901    // ── Combined operations ─────────────────────────────────────────────
1902
1903    #[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        // Insert at line 1 and delete at line 3 → first changed is 1.
1913        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}