Skip to main content

oxicode_hashline/
parser.rs

1//! Token-driven state machine that turns a stream of [`Token`]s into a flat
2//! list of [`Edit`]s, plus the top-level envelope splitter that carves an
3//! authored patch into [`PatchSection`]s.
4//!
5//! Ported from omp `packages/hashline/src/parser.ts` (the `Executor` state
6//! machine) and `packages/hashline/src/input.ts` (`splitPatchInput`,
7//! `PatchSection`).
8
9use std::collections::HashMap;
10use std::path::{Component, Path};
11
12use crate::format::{
13    HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP,
14};
15use crate::messages::{
16    BARE_BODY_AUTO_PIPED_WARNING, DELETE_TAKES_NO_BODY, EMPTY_INSERT, MINUS_ROW_REJECTED,
17};
18use crate::mismatch::HashlineError;
19use crate::tokenizer::{BlockTarget, Token, Tokenizer, classify_line, split_hashline_lines};
20use crate::types::{Anchor, Cursor, Edit, InsertMode, ParsedRange, SplitOptions};
21
22// ── Internal error ───────────────────────────────────────────────────────
23
24/// A parse failure carrying the source line number and a focused message.
25/// Converted to [`HashlineError::Parse`] at public boundaries.
26#[derive(Debug)]
27pub(crate) struct ParseError {
28    line: u32,
29    msg: String,
30}
31
32fn perr(line: u32, msg: impl Into<String>) -> ParseError {
33    ParseError {
34        line,
35        msg: msg.into(),
36    }
37}
38
39type PResult<T> = Result<T, ParseError>;
40
41impl ParseError {
42    fn into_hash(self) -> HashlineError {
43        HashlineError::parse(self.line, self.msg)
44    }
45}
46
47// ── Small byte helpers (parser-local) ────────────────────────────────────
48
49#[inline]
50fn is_ws(b: u8) -> bool {
51    b == b' ' || (b'\t'..=b'\r').contains(&b)
52}
53
54#[inline]
55fn is_digit(b: u8) -> bool {
56    b.is_ascii_digit()
57}
58
59#[inline]
60fn is_nonzero_digit(b: u8) -> bool {
61    (b'1'..=b'9').contains(&b)
62}
63
64#[inline]
65fn is_hex(b: u8) -> bool {
66    b.is_ascii_hexdigit()
67}
68
69#[inline]
70fn is_alnum(b: u8) -> bool {
71    b.is_ascii_alphanumeric()
72}
73
74// ── Read-output prefix stripping (prefixes.ts) ───────────────────────────
75
76/// Strip at most one leading hashline/snapshot prefix (`N:`, `>>>N:`, `+N:`,
77/// `*-N:` …). Single-pass: does NOT loop, so genuine content beginning with
78/// `digits:` is left intact when not uniformly prefixed.
79///
80/// Mirrors omp `stripOneLeadingHashlinePrefix` / `HL_PREFIX_RE`.
81fn strip_one_leading_hashline_prefix(line: &str) -> String {
82    let bytes = line.as_bytes();
83    let n = bytes.len();
84    let mut i = 0;
85    while i < n && is_ws(bytes[i]) {
86        i += 1;
87    }
88    if bytes[i..].starts_with(b">>>") {
89        i += 3;
90    } else if bytes[i..].starts_with(b">>") {
91        i += 2;
92    }
93    while i < n && is_ws(bytes[i]) {
94        i += 1;
95    }
96    if i < n && (bytes[i] == b'+' || bytes[i] == b'*' || bytes[i] == b'-') {
97        i += 1;
98        while i < n && is_ws(bytes[i]) {
99            i += 1;
100        }
101    }
102    if i < n && is_digit(bytes[i]) {
103        while i < n && is_digit(bytes[i]) {
104            i += 1;
105        }
106        if i < n && bytes[i] == b':' {
107            return line[i + 1..].to_string();
108        }
109    }
110    line.to_string()
111}
112
113/// A stripped remainder that is a lone quoted or numeric literal (optionally
114/// comma-terminated) — the shape of a numeric-keyed dict/YAML body rather than
115/// read-output paste. Mirrors omp `BARE_LITERAL_VALUE_RE`.
116fn is_bare_literal_value(s: &str) -> bool {
117    let bytes = s.as_bytes();
118    let n = bytes.len();
119    let mut i = 0;
120    while i < n && is_ws(bytes[i]) {
121        i += 1;
122    }
123    let matched = if i < n && (bytes[i] == b'"' || bytes[i] == b'\'') {
124        let quote = bytes[i];
125        i += 1;
126        while i < n && bytes[i] != quote {
127            i += 1;
128        }
129        if i >= n {
130            return false; // unterminated quote
131        }
132        i += 1; // closing quote
133        true
134    } else {
135        if i < n && (bytes[i] == b'-' || bytes[i] == b'+') {
136            i += 1;
137        }
138        if i >= n || !is_digit(bytes[i]) {
139            return false;
140        }
141        while i < n && is_digit(bytes[i]) {
142            i += 1;
143        }
144        if i < n && bytes[i] == b'.' {
145            i += 1;
146            if i >= n || !is_digit(bytes[i]) {
147                return false;
148            }
149            while i < n && is_digit(bytes[i]) {
150                i += 1;
151            }
152        }
153        true
154    };
155    while i < n && is_ws(bytes[i]) {
156        i += 1;
157    }
158    if i < n && bytes[i] == b',' {
159        i += 1;
160    }
161    while i < n && is_ws(bytes[i]) {
162        i += 1;
163    }
164    matched && i == n
165}
166
167/// Strip a single read-output `N:` prefix from every bare body row, but only
168/// when *all* bare rows carry one (and the result is not a dict/YAML literal
169/// body). Mirrors omp `Executor.#stripBarePrefixesIfUniform`.
170fn strip_bare_prefixes_if_uniform(payloads: &mut [PayloadRows]) {
171    let mut saw_bare = false;
172    let mut all_literal_values = true;
173    for row in payloads.iter() {
174        if !row.bare || row.text.trim().is_empty() {
175            continue;
176        }
177        saw_bare = true;
178        let stripped = strip_one_leading_hashline_prefix(&row.text);
179        if stripped == row.text {
180            return; // not every bare row carries a prefix → leave untouched
181        }
182        if all_literal_values && !is_bare_literal_value(&stripped) {
183            all_literal_values = false;
184        }
185    }
186    if !saw_bare || all_literal_values {
187        return;
188    }
189    for row in payloads.iter_mut() {
190        if row.bare && !row.text.trim().is_empty() {
191            row.text = strip_one_leading_hashline_prefix(&row.text);
192        }
193    }
194}
195
196// ── Contamination detection (parser.ts) ─────────────────────────────────
197
198/// Detect apply_patch / unified-diff contamination that is not valid in
199/// hashline. Returns a focused error message when the line is a known foreign
200/// shape, else `None`. Mirrors omp `detectApplyPatchContamination`.
201fn detect_apply_patch_contamination(text: &str, _has_pending: bool) -> Option<String> {
202    let trimmed = text.trim_start();
203    if trimmed.is_empty() {
204        return None;
205    }
206
207    if trimmed.starts_with("*** Update File:")
208        || trimmed.starts_with("*** Add File:")
209        || trimmed.starts_with("*** Delete File:")
210        || trimmed.starts_with("*** Move to:")
211    {
212        return Some(format!(
213            "apply_patch sentinel {prev} is not valid in hashline. File sections start with \
214             `[path#HASH]` (no `Update File:` / `Add File:` keyword). Use `SWAP N.=M:`, \
215             `DEL N.=M`, or `INS.PRE|POST|HEAD|TAIL:` ops.",
216            prev = contamination_preview(trimmed)
217        ));
218    }
219    if is_unified_diff_hunk(trimmed) {
220        return Some(
221            "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. Use \
222             `SWAP N.=M:`, `DEL N.=M`, or `INS.PRE|POST|HEAD|TAIL:` ops."
223                .to_string(),
224        );
225    }
226    if trimmed.starts_with("@@") {
227        return Some(format!(
228            "`@@`-bracketed hunk header {prev} is not valid in hashline. Drop the `@@ ... @@` \
229             brackets and write a verb header such as `SWAP N.=M:`.",
230            prev = contamination_preview(trimmed)
231        ));
232    }
233    if is_del_with_colon(trimmed) {
234        return Some(
235            "`DEL N.=M` has no colon and no body. Remove the colon and body rows.".to_string(),
236        );
237    }
238    if is_bare_line_number(trimmed) {
239        let n = trimmed.trim();
240        return Some(format!(
241            "hunk headers need a verb. Use `SWAP {n}{sep}{n}:` to replace, or `DEL {n}` to delete.",
242            sep = HL_RANGE_SEP
243        ));
244    }
245    if let Some((a, b)) = parse_bare_range(trimmed) {
246        return Some(format!(
247            "bare range hunk header `{trimmed}` is not valid. Hunk headers need a verb: write \
248             `SWAP {a}{sep}{b}:` or `DEL {a}{sep}{b}`.",
249            sep = HL_RANGE_SEP
250        ));
251    }
252    None
253}
254
255/// `@@ -N,M +N,M @@` unified-diff hunk header shape.
256fn is_unified_diff_hunk(s: &str) -> bool {
257    let b = s.as_bytes();
258    let n = b.len();
259    if !b.starts_with(b"@@") {
260        return false;
261    }
262    let Some(mut j) = ws1(b, 2, n) else {
263        return false;
264    };
265    j = opt_sign(b, j);
266    let Some(k) = digits(b, j, n) else {
267        return false;
268    };
269    if k >= n || b[k] != b',' {
270        return false;
271    }
272    let Some(k2) = digits(b, k + 1, n) else {
273        return false;
274    };
275    let Some(k3) = ws1(b, k2, n) else {
276        return false;
277    };
278    let k4 = opt_sign(b, k3);
279    let Some(k5) = digits(b, k4, n) else {
280        return false;
281    };
282    if k5 >= n || b[k5] != b',' {
283        return false;
284    }
285    let Some(k6) = digits(b, k5 + 1, n) else {
286        return false;
287    };
288    let Some(k7) = ws1(b, k6, n) else {
289        return false;
290    };
291    b[k7..].starts_with(b"@@")
292}
293
294/// `DEL N` or `DEL N.=M` followed by a stray colon.
295fn is_del_with_colon(s: &str) -> bool {
296    let b = s.as_bytes();
297    let n = b.len();
298    if !b.starts_with(b"DEL") {
299        return false;
300    }
301    let mut i = 3;
302    let ws_start = i;
303    while i < n && is_ws(b[i]) {
304        i += 1;
305    }
306    if i == ws_start || i >= n || !is_nonzero_digit(b[i]) {
307        return false;
308    }
309    i += 1;
310    while i < n && is_digit(b[i]) {
311        i += 1;
312    }
313    let after_num = i;
314    // optional (sep + second number)
315    let mut k = i;
316    while k < n && is_ws(b[k]) {
317        k += 1;
318    }
319    let consumed_sep_ws = k > i;
320    let after_sep = if b[k..n].starts_with(b"..") || b[k..n].starts_with(b".=") {
321        Some(k + 2)
322    } else if k < n && b[k] == b'-' {
323        Some(k + 1)
324    } else if b[k..n].starts_with("\u{2026}".as_bytes()) {
325        Some(k + 3)
326    } else if consumed_sep_ws {
327        Some(k)
328    } else {
329        None
330    };
331    if let Some(k2) = after_sep {
332        let mut k3 = k2;
333        while k3 < n && is_ws(b[k3]) {
334            k3 += 1;
335        }
336        if k3 < n && is_nonzero_digit(b[k3]) {
337            k3 += 1;
338            while k3 < n && is_digit(b[k3]) {
339                k3 += 1;
340            }
341            i = k3;
342        } else {
343            i = after_num;
344        }
345    }
346    while i < n && is_ws(b[i]) {
347        i += 1;
348    }
349    i < n && b[i] == b':'
350}
351
352/// A bare line number (`42`, with optional trailing whitespace).
353fn is_bare_line_number(s: &str) -> bool {
354    let b = s.as_bytes();
355    if b.is_empty() || !is_nonzero_digit(b[0]) {
356        return false;
357    }
358    let mut i = 1;
359    while i < b.len() && is_digit(b[i]) {
360        i += 1;
361    }
362    while i < b.len() && is_ws(b[i]) {
363        i += 1;
364    }
365    i == b.len()
366}
367
368/// A bare range `N … M` (optionally colon-terminated). Returns the two numbers.
369fn parse_bare_range(s: &str) -> Option<(String, String)> {
370    let b = s.as_bytes();
371    let n = b.len();
372    if b.is_empty() || !is_nonzero_digit(b[0]) {
373        return None;
374    }
375    let mut i = 1;
376    while i < n && is_digit(b[i]) {
377        i += 1;
378    }
379    let first = s[..i].to_string();
380    while i < n && is_ws(b[i]) {
381        i += 1;
382    }
383    let sep_start = i;
384    while i < n {
385        let c = b[i];
386        if c == b'-' || c == b'.' || c == b'=' || is_ws(c) {
387            i += 1;
388        } else if b[i..n].starts_with("\u{2026}".as_bytes()) {
389            i += 3;
390        } else {
391            break;
392        }
393    }
394    if i == sep_start {
395        return None;
396    }
397    while i < n && is_ws(b[i]) {
398        i += 1;
399    }
400    if i >= n || !is_nonzero_digit(b[i]) {
401        return None;
402    }
403    let second_start = i;
404    i += 1;
405    while i < n && is_digit(b[i]) {
406        i += 1;
407    }
408    let second = s[second_start..i].to_string();
409    while i < n && is_ws(b[i]) {
410        i += 1;
411    }
412    if i < n && b[i] == b':' {
413        i += 1;
414    }
415    if i != n {
416        return None;
417    }
418    Some((first, second))
419}
420
421#[inline]
422fn opt_sign(b: &[u8], i: usize) -> usize {
423    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
424        i + 1
425    } else {
426        i
427    }
428}
429
430fn digits(b: &[u8], i: usize, n: usize) -> Option<usize> {
431    let start = i;
432    let mut j = i;
433    while j < n && is_digit(b[j]) {
434        j += 1;
435    }
436    (j != start).then_some(j)
437}
438
439fn ws1(b: &[u8], i: usize, n: usize) -> Option<usize> {
440    let start = i;
441    let mut j = i;
442    while j < n && is_ws(b[j]) {
443        j += 1;
444    }
445    (j != start).then_some(j)
446}
447
448fn contamination_preview(trimmed: &str) -> String {
449    const MAX: usize = 48;
450    let chars: Vec<char> = trimmed.chars().collect();
451    let preview = if chars.len() > MAX {
452        let head: String = chars[..MAX].iter().collect();
453        format!("{head}\u{2026}")
454    } else {
455        trimmed.to_string()
456    };
457    json_quote(&preview)
458}
459
460fn json_truncated(s: &str, max: usize) -> String {
461    let taken: String = s.chars().take(max).collect();
462    json_quote(&taken)
463}
464
465fn json_quote(s: &str) -> String {
466    let mut out = String::with_capacity(s.len() + 2);
467    out.push('"');
468    for c in s.chars() {
469        match c {
470            '"' => out.push_str("\\\""),
471            '\\' => out.push_str("\\\\"),
472            '\n' => out.push_str("\\n"),
473            '\r' => out.push_str("\\r"),
474            '\t' => out.push_str("\\t"),
475            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
476            c => out.push(c),
477        }
478    }
479    out.push('"');
480    out
481}
482
483// ── Range / comment helpers ──────────────────────────────────────────────
484
485fn validate_range_order(range: ParsedRange, line_num: u32) -> PResult<()> {
486    if range.end < range.start {
487        return Err(perr(
488            line_num,
489            format!(
490                "range {a}{sep}{b} ends before it starts.",
491                a = range.start,
492                b = range.end,
493                sep = HL_RANGE_SEP
494            ),
495        ));
496    }
497    Ok(())
498}
499
500fn expand_range(range: ParsedRange) -> Vec<Anchor> {
501    (range.start..=range.end)
502        .map(|line| Anchor { line })
503        .collect()
504}
505
506fn is_skippable_comment_line(line: &str) -> bool {
507    line.trim_start().starts_with('#')
508}
509
510// ── Executor state machine ───────────────────────────────────────────────
511
512#[derive(Debug, Clone)]
513struct PayloadRows {
514    text: String,
515    bare: bool,
516}
517
518struct Pending {
519    target: BlockTarget,
520    line_num: u32,
521    payloads: Vec<PayloadRows>,
522    deferred_blanks: Vec<PayloadRows>,
523}
524
525struct PendingComment {
526    line_num: u32,
527    text: String,
528}
529
530/// Token-driven state machine: feeds produce pending hunks; a new op or the
531/// final flush lowers each hunk into [`Edit`]s. Mirrors omp `Executor`.
532pub(crate) struct Executor {
533    edits: Vec<Edit>,
534    warnings: Vec<String>,
535    edit_index: usize,
536    pending: Option<Pending>,
537    terminated: bool,
538    skippable_comments: Vec<PendingComment>,
539}
540
541impl Default for Executor {
542    fn default() -> Self {
543        Self::new()
544    }
545}
546
547impl Executor {
548    pub fn new() -> Self {
549        Self {
550            edits: Vec::new(),
551            warnings: Vec::new(),
552            edit_index: 0,
553            pending: None,
554            terminated: false,
555            skippable_comments: Vec::new(),
556        }
557    }
558
559    /// Feed one token. Errors on illegal hunk shapes / contamination.
560    pub(crate) fn feed(&mut self, token: Token) -> PResult<()> {
561        if self.terminated {
562            return Ok(());
563        }
564        match token {
565            Token::EnvelopeBegin { .. } => {
566                self.consume_pending_skippable_comments()?;
567            }
568            Token::EnvelopeEnd { .. } => {
569                self.consume_pending_skippable_comments()?;
570                self.terminated = true;
571            }
572            Token::Abort { .. } => {
573                self.terminated = true;
574            }
575            Token::Header { .. } => {
576                self.consume_pending_skippable_comments()?;
577                self.flush_pending()?;
578            }
579            Token::Blank { .. } => {
580                self.consume_pending_skippable_comments()?;
581                self.handle_blank("");
582            }
583            Token::PayloadLiteral { text, line_num, .. } => {
584                self.consume_pending_skippable_comments()?;
585                self.handle_literal_payload(text, line_num)?;
586            }
587            Token::Raw { text, line_num, .. } => {
588                if self.pending.is_none() && is_skippable_comment_line(&text) {
589                    self.skippable_comments
590                        .push(PendingComment { line_num, text });
591                } else {
592                    self.consume_pending_skippable_comments()?;
593                    self.handle_raw(text, line_num)?;
594                }
595            }
596            Token::Op {
597                target, line_num, ..
598            } => {
599                self.discard_pending_skippable_comments();
600                if let BlockTarget::Replace { range } | BlockTarget::Delete { range } = &target {
601                    validate_range_order(*range, line_num)?;
602                }
603                self.flush_pending()?;
604                self.pending = Some(Pending {
605                    target,
606                    line_num,
607                    payloads: Vec::new(),
608                    deferred_blanks: Vec::new(),
609                });
610            }
611        }
612        Ok(())
613    }
614
615    /// Drain pending and validate the final edit list (strict path).
616    pub(crate) fn finish(mut self) -> PResult<(Vec<Edit>, Vec<String>)> {
617        self.consume_pending_skippable_comments()?;
618        self.flush_pending()?;
619        self.validate_no_overlapping_deletes()?;
620        Ok((self.edits, self.warnings))
621    }
622
623    /// Streaming-tolerant finish: a trailing op with no payload yet is dropped
624    /// rather than emitting a phantom empty-payload error.
625    pub(crate) fn finish_streaming(mut self) -> PResult<(Vec<Edit>, Vec<String>)> {
626        self.consume_pending_skippable_comments()?;
627        if let Some(pending) = self.pending.take() {
628            let flush = !pending.payloads.is_empty()
629                || matches!(pending.target, BlockTarget::Delete { .. });
630            if flush {
631                self.pending = Some(pending);
632                self.flush_pending()?;
633            }
634        }
635        self.validate_no_overlapping_deletes()?;
636        Ok((self.edits, self.warnings))
637    }
638
639    fn discard_pending_skippable_comments(&mut self) {
640        self.skippable_comments.clear();
641    }
642
643    fn consume_pending_skippable_comments(&mut self) -> PResult<()> {
644        if self.skippable_comments.is_empty() {
645            return Ok(());
646        }
647        let comments = std::mem::take(&mut self.skippable_comments);
648        for c in comments {
649            self.handle_raw(c.text, c.line_num)?;
650        }
651        Ok(())
652    }
653
654    fn warn(&mut self, msg: &'static str) {
655        if !self.warnings.iter().any(|w| w == msg) {
656            self.warnings.push(msg.to_string());
657        }
658    }
659
660    fn handle_literal_payload(&mut self, text: String, line_num: u32) -> PResult<()> {
661        let Some(mut pending) = self.pending.take() else {
662            return Err(perr(
663                line_num,
664                format!("payload line has no preceding hunk header. Got `+{text}`."),
665            ));
666        };
667        if matches!(pending.target, BlockTarget::Delete { .. }) {
668            self.pending = Some(pending);
669            return Err(perr(line_num, DELETE_TAKES_NO_BODY.to_string()));
670        }
671        self.commit_deferred_blanks(&mut pending);
672        pending.payloads.push(PayloadRows { text, bare: false });
673        self.pending = Some(pending);
674        Ok(())
675    }
676
677    fn handle_raw(&mut self, text: String, line_num: u32) -> PResult<()> {
678        if let Some(msg) = detect_apply_patch_contamination(&text, self.pending.is_some()) {
679            return Err(perr(line_num, msg));
680        }
681        let Some(mut pending) = self.pending.take() else {
682            if text.trim().is_empty() {
683                return Ok(());
684            }
685            return Err(perr(
686                line_num,
687                format!(
688                    "payload line has no preceding hunk header. Use `SWAP N.=M:`, `DEL N.=M`, \
689                     or `INS.PRE|POST|HEAD|TAIL:` above the body. Got `{text}`."
690                ),
691            ));
692        };
693        if text.trim().is_empty() {
694            self.pending = Some(pending);
695            self.handle_blank(&text);
696            return Ok(());
697        }
698        let is_delete = matches!(pending.target, BlockTarget::Delete { .. });
699        let is_minus = text.trim_start().as_bytes().first() == Some(&b'-');
700        if is_delete || is_minus {
701            self.pending = Some(pending);
702            let msg = if is_delete {
703                DELETE_TAKES_NO_BODY
704            } else {
705                MINUS_ROW_REJECTED
706            };
707            return Err(perr(line_num, msg.to_string()));
708        }
709        self.warn(BARE_BODY_AUTO_PIPED_WARNING);
710        self.commit_deferred_blanks(&mut pending);
711        pending.payloads.push(PayloadRows { text, bare: true });
712        self.pending = Some(pending);
713        Ok(())
714    }
715
716    fn handle_blank(&mut self, text: &str) {
717        let Some(pending) = self.pending.as_mut() else {
718            return;
719        };
720        if matches!(pending.target, BlockTarget::Delete { .. }) {
721            return;
722        }
723        if pending.payloads.is_empty() {
724            return;
725        }
726        pending.deferred_blanks.push(PayloadRows {
727            text: text.to_string(),
728            bare: true,
729        });
730    }
731
732    fn commit_deferred_blanks(&mut self, pending: &mut Pending) {
733        if pending.deferred_blanks.is_empty() {
734            return;
735        }
736        self.warn(BARE_BODY_AUTO_PIPED_WARNING);
737        let mut blanks = std::mem::take(&mut pending.deferred_blanks);
738        pending.payloads.append(&mut blanks);
739    }
740
741    fn flush_pending(&mut self) -> PResult<()> {
742        let Some(pending) = self.pending.take() else {
743            return Ok(());
744        };
745        let Pending {
746            target,
747            line_num,
748            mut payloads,
749            ..
750        } = pending;
751        strip_bare_prefixes_if_uniform(&mut payloads);
752        match target {
753            BlockTarget::Delete { range } => {
754                for anchor in expand_range(range) {
755                    self.push_delete(anchor, line_num);
756                }
757            }
758            BlockTarget::Replace { range } => {
759                if payloads.is_empty() {
760                    for anchor in expand_range(range) {
761                        self.push_delete(anchor, line_num);
762                    }
763                } else {
764                    let cursor = Cursor::BeforeAnchor(Anchor { line: range.start });
765                    self.emit_payload_rows(
766                        cursor,
767                        &payloads,
768                        line_num,
769                        Some(InsertMode::Replacement),
770                    );
771                    for anchor in expand_range(range) {
772                        self.push_delete(anchor, line_num);
773                    }
774                }
775            }
776            BlockTarget::InsertBefore { anchor } => {
777                if payloads.is_empty() {
778                    return Err(perr(line_num, EMPTY_INSERT.to_string()));
779                }
780                self.emit_payload_rows(Cursor::BeforeAnchor(anchor), &payloads, line_num, None);
781            }
782            BlockTarget::InsertAfter { anchor } => {
783                if payloads.is_empty() {
784                    return Err(perr(line_num, EMPTY_INSERT.to_string()));
785                }
786                self.emit_payload_rows(Cursor::AfterAnchor(anchor), &payloads, line_num, None);
787            }
788            BlockTarget::Bof => {
789                if payloads.is_empty() {
790                    return Err(perr(line_num, EMPTY_INSERT.to_string()));
791                }
792                self.emit_payload_rows(Cursor::Bof, &payloads, line_num, None);
793            }
794            BlockTarget::Eof => {
795                if payloads.is_empty() {
796                    return Err(perr(line_num, EMPTY_INSERT.to_string()));
797                }
798                self.emit_payload_rows(Cursor::Eof, &payloads, line_num, None);
799            }
800        }
801        Ok(())
802    }
803
804    fn push_insert(
805        &mut self,
806        cursor: Cursor,
807        text: String,
808        line_num: u32,
809        mode: Option<InsertMode>,
810    ) {
811        self.edits.push(Edit::Insert {
812            cursor,
813            text,
814            line_num,
815            index: self.edit_index,
816            mode,
817        });
818        self.edit_index += 1;
819    }
820
821    fn push_delete(&mut self, anchor: Anchor, line_num: u32) {
822        self.edits.push(Edit::Delete {
823            anchor,
824            line_num,
825            index: self.edit_index,
826            old_assertion: None,
827        });
828        self.edit_index += 1;
829    }
830
831    fn emit_payload_rows(
832        &mut self,
833        cursor: Cursor,
834        payloads: &[PayloadRows],
835        line_num: u32,
836        mode: Option<InsertMode>,
837    ) {
838        for p in payloads {
839            self.push_insert(cursor.clone(), p.text.clone(), line_num, mode);
840        }
841    }
842
843    fn validate_no_overlapping_deletes(&self) -> PResult<()> {
844        let mut by_anchor: HashMap<u32, Vec<u32>> = HashMap::new();
845        for edit in &self.edits {
846            if let Edit::Delete {
847                anchor, line_num, ..
848            } = edit
849            {
850                let v = by_anchor.entry(anchor.line).or_default();
851                if !v.contains(line_num) {
852                    v.push(*line_num);
853                }
854            }
855        }
856        for (anchor_line, mut source_lines) in by_anchor {
857            if source_lines.len() < 2 {
858                continue;
859            }
860            source_lines.sort_unstable();
861            let first = source_lines[0];
862            let second = source_lines[1];
863            return Err(perr(
864                second,
865                format!(
866                    "anchor line {anchor_line} is already targeted by another hunk on line \
867                     {first}. Issue ONE hunk per range; payload is only the final desired \
868                     content, never a before/after pair."
869                ),
870            ));
871        }
872        Ok(())
873    }
874}
875
876// ── Standalone diff → edits ──────────────────────────────────────────────
877
878/// Parse a single section's diff body into `(edits, warnings)`. Mirrors omp
879/// `parsePatch`.
880pub fn parse_patch(diff: &str) -> Result<(Vec<Edit>, Vec<String>), HashlineError> {
881    let mut tokenizer = Tokenizer::new();
882    let mut executor = Executor::new();
883    for token in tokenizer.tokenize_all(diff) {
884        executor.feed(token).map_err(ParseError::into_hash)?;
885    }
886    executor.finish().map_err(ParseError::into_hash)
887}
888
889/// Streaming-tolerant variant of [`parse_patch`]. Mirrors omp
890/// `parsePatchStreaming`.
891pub fn parse_patch_streaming(diff: &str) -> Result<(Vec<Edit>, Vec<String>), HashlineError> {
892    let mut tokenizer = Tokenizer::new();
893    let mut executor = Executor::new();
894    for token in tokenizer.tokenize_all(diff) {
895        executor.feed(token).map_err(ParseError::into_hash)?;
896    }
897    executor.finish_streaming().map_err(ParseError::into_hash)
898}
899
900// ── Envelope splitting (input.ts) ────────────────────────────────────────
901
902#[derive(Debug, Clone)]
903struct RawSection {
904    path: String,
905    file_hash: Option<String>,
906    diff: String,
907}
908
909fn unquote_hashline_path(path_text: &str) -> String {
910    let bytes = path_text.as_bytes();
911    if bytes.len() < 2 {
912        return path_text.to_string();
913    }
914    let first = bytes[0];
915    let last = bytes[bytes.len() - 1];
916    if (first == b'"' || first == b'\'') && first == last {
917        path_text[1..bytes.len() - 1].to_string()
918    } else {
919        path_text.to_string()
920    }
921}
922
923fn consume_stars(bytes: &[u8], mut i: usize, n: usize) -> usize {
924    let mut count = 0;
925    while i < n && bytes[i] == b'*' && count < 3 {
926        i += 1;
927        count += 1;
928    }
929    i
930}
931
932fn match_word_ci(bytes: &[u8], i: usize, n: usize, word: &[u8]) -> Option<usize> {
933    if i + word.len() <= n && bytes[i..i + word.len()].eq_ignore_ascii_case(word) {
934        Some(i + word.len())
935    } else {
936        None
937    }
938}
939
940fn find_keyword_colon(bytes: &[u8], verb_end: usize, n: usize) -> Option<usize> {
941    let mut j = verb_end;
942    while j < n && bytes[j] != b':' && !is_alnum(bytes[j]) {
943        j += 1;
944    }
945    if j < n && bytes[j] == b':' {
946        return Some(j);
947    }
948    if j < n && is_alnum(bytes[j]) {
949        let after_word =
950            match_word_ci(bytes, j, n, b"file").or_else(|| match_word_ci(bytes, j, n, b"to"));
951        let after_word = after_word?;
952        let mut k = after_word;
953        while k < n && bytes[k] != b':' && !is_alnum(bytes[k]) {
954            k += 1;
955        }
956        if k < n && bytes[k] == b':' {
957            return Some(k);
958        }
959    }
960    None
961}
962
963/// Strip apply_patch-style noise models prepend to the path
964/// (`***`, `Update File:`, `Add File:`, `Move to:` …). Mirrors omp
965/// `stripApplyPatchPathNoise` / `APPLY_PATCH_PATH_NOISE_RE`.
966fn strip_apply_patch_path_noise(s: &str) -> String {
967    let bytes = s.as_bytes();
968    let n = bytes.len();
969    let mut i = consume_stars(bytes, 0, n);
970    while i < n && is_ws(bytes[i]) {
971        i += 1;
972    }
973    let after_lead = i;
974    if let Some(verb_end) = ["update", "add", "delete", "move"]
975        .iter()
976        .find_map(|v| match_word_ci(bytes, i, n, v.as_bytes()))
977    {
978        if let Some(colon) = find_keyword_colon(bytes, verb_end, n) {
979            i = colon + 1;
980        } else {
981            i = after_lead;
982        }
983    }
984    while i < n && is_ws(bytes[i]) {
985        i += 1;
986    }
987    i = consume_stars(bytes, i, n);
988    while i < n && is_ws(bytes[i]) {
989        i += 1;
990    }
991    s[i..].to_string()
992}
993
994/// Detect a trailing `#XXXX` (4-hex) snapshot tag at the end of a body.
995/// Returns `(hash_uppercase, index_of_hash)`.
996fn trailing_hash(body: &str) -> Option<(String, usize)> {
997    let trimmed = body.trim_end();
998    let bytes = trimmed.as_bytes();
999    let n = bytes.len();
1000    if n < HL_FILE_HASH_LENGTH + 1 {
1001        return None;
1002    }
1003    let hash_start = n - HL_FILE_HASH_LENGTH;
1004    if hash_start == 0 || bytes[hash_start - 1] != b'#' {
1005        return None;
1006    }
1007    for &b in &bytes[hash_start..n] {
1008        if !is_hex(b) {
1009            return None;
1010        }
1011    }
1012    Some((trimmed[hash_start..n].to_uppercase(), hash_start - 1))
1013}
1014
1015/// Best-effort recovery for bracketed header lines the strict tokenizer
1016/// rejects. Mirrors omp `tryParseRecoveryHeader`.
1017fn try_parse_recovery_header(line: &str, cwd: Option<&Path>) -> Option<RawSection> {
1018    if !line.starts_with(HL_FILE_PREFIX) || !line.ends_with(HL_FILE_SUFFIX) {
1019        return None;
1020    }
1021    let inner_start = HL_FILE_PREFIX.len();
1022    let inner_end = line.len().saturating_sub(HL_FILE_SUFFIX.len());
1023    if inner_start >= inner_end {
1024        return None;
1025    }
1026    let body = strip_apply_patch_path_noise(line[inner_start..inner_end].trim());
1027    if body.is_empty() {
1028        return None;
1029    }
1030    let (path_text, file_hash) = match trailing_hash(&body) {
1031        Some((hash, idx)) => (body[..idx].to_string(), Some(hash)),
1032        None => (body.trim_end().to_string(), None),
1033    };
1034    if path_text.contains('#') {
1035        return None;
1036    }
1037    let path = normalize_hashline_path(&path_text, cwd);
1038    if path.is_empty() {
1039        return None;
1040    }
1041    Some(RawSection {
1042        path,
1043        file_hash,
1044        diff: String::new(),
1045    })
1046}
1047
1048/// Lexically normalize an absolute path: drop `.` components and resolve `..`
1049/// against preceding normal components. No symlink resolution (pure lib).
1050fn lexical_normalize_abs(p: &Path) -> std::path::PathBuf {
1051    let mut out = std::path::PathBuf::new();
1052    for comp in p.components() {
1053        match comp {
1054            Component::CurDir => {}
1055            Component::ParentDir => {
1056                if matches!(out.components().next_back(), Some(Component::Normal(_))) {
1057                    out.pop();
1058                } else {
1059                    out.push("..");
1060                }
1061            }
1062            other => out.push(other.as_os_str()),
1063        }
1064    }
1065    out
1066}
1067
1068/// Compute the relative path from `from` (a directory) to `to`. Both must be
1069/// absolute; returns `None` otherwise.
1070fn lexical_relative(from: &Path, to: &Path) -> Option<std::path::PathBuf> {
1071    if !from.is_absolute() || !to.is_absolute() {
1072        return None;
1073    }
1074    let from_c: Vec<Component<'_>> = from.components().collect();
1075    let to_c: Vec<Component<'_>> = to.components().collect();
1076    let mut i = 0;
1077    while i < from_c.len() && i < to_c.len() && from_c[i] == to_c[i] {
1078        i += 1;
1079    }
1080    let mut result = std::path::PathBuf::new();
1081    for _ in i..from_c.len() {
1082        result.push("..");
1083    }
1084    for c in &to_c[i..] {
1085        result.push(c.as_os_str());
1086    }
1087    Some(result)
1088}
1089
1090/// Normalize a header path: unquote, strip apply_patch noise, and (when `cwd`
1091/// is given) resolve an absolute path to a cwd-relative form. Mirrors omp
1092/// `normalizeHashlinePath`.
1093fn normalize_hashline_path(raw_path: &str, cwd: Option<&Path>) -> String {
1094    let unquoted = strip_apply_patch_path_noise(&unquote_hashline_path(raw_path.trim()));
1095    let Some(cwd) = cwd else {
1096        return unquoted;
1097    };
1098    let p = Path::new(&unquoted);
1099    if !p.is_absolute() {
1100        return unquoted;
1101    }
1102    let cwd_abs = lexical_normalize_abs(cwd);
1103    let target_abs = lexical_normalize_abs(p);
1104    let Some(rel) = lexical_relative(&cwd_abs, &target_abs) else {
1105        return unquoted;
1106    };
1107    let normalized = rel.to_string_lossy().replace('\\', "/");
1108    if normalized.is_empty() {
1109        ".".to_string()
1110    } else if normalized.starts_with("..") {
1111        unquoted
1112    } else {
1113        normalized
1114    }
1115}
1116
1117/// Parse a `[PATH]` / `[PATH#HASH]` header line. `Ok(None)` for non-bracketed
1118/// lines; `Err` for bracketed lines whose strict shape fails AND recovery
1119/// cannot salvage them. Mirrors omp `parseHashlineHeaderLine`.
1120fn parse_hashline_header_line(
1121    line: &str,
1122    cwd: Option<&Path>,
1123) -> Result<Option<RawSection>, HashlineError> {
1124    let trimmed = line.trim_end();
1125    if !trimmed.starts_with(HL_FILE_PREFIX) {
1126        return Ok(None);
1127    }
1128    let token = classify_line(trimmed, 0);
1129    if !matches!(token, Token::Header { .. }) {
1130        if let Some(recovered) = try_parse_recovery_header(trimmed, cwd) {
1131            return Ok(Some(recovered));
1132        }
1133        return Err(HashlineError::parse(
1134            0,
1135            format!(
1136                "Input header must be {p}PATH{e} or {p}PATH{s}TAG{e} with a {len}-hex \
1137                 content-hash tag; got `{trimmed}`.",
1138                p = HL_FILE_PREFIX,
1139                e = HL_FILE_SUFFIX,
1140                s = HL_FILE_HASH_SEP,
1141                len = HL_FILE_HASH_LENGTH
1142            ),
1143        ));
1144    }
1145    let Token::Header {
1146        path, file_hash, ..
1147    } = token
1148    else {
1149        unreachable!("matched Header above");
1150    };
1151    let parsed_path = normalize_hashline_path(&path, cwd);
1152    if parsed_path.is_empty() {
1153        return Err(HashlineError::parse(
1154            0,
1155            format!(
1156                "Input header `{p}{e}` is empty; provide a file path.",
1157                p = HL_FILE_PREFIX,
1158                e = HL_FILE_SUFFIX
1159            ),
1160        ));
1161    }
1162    Ok(Some(RawSection {
1163        path: parsed_path,
1164        file_hash,
1165        diff: String::new(),
1166    }))
1167}
1168
1169/// Strip a leading BOM and any leading blank / `*** Begin Patch` lines.
1170/// Mirrors omp `stripLeadingBlankLines`.
1171fn strip_leading_blank_lines(input: &str) -> String {
1172    let stripped = input.strip_prefix('\u{feff}').unwrap_or(input);
1173    let mut lines = split_hashline_lines(stripped);
1174    let tok = Tokenizer::new();
1175    let mut idx = 0;
1176    while idx < lines.len() {
1177        let head = &lines[idx];
1178        if head.trim().is_empty() || matches!(tok.tokenize(head, 0), Token::EnvelopeBegin { .. }) {
1179            idx += 1;
1180            continue;
1181        }
1182        break;
1183    }
1184    if idx == 0 {
1185        return stripped.to_string();
1186    }
1187    lines.drain(..idx);
1188    lines.join("\n")
1189}
1190
1191fn flush_section(
1192    current: &mut Option<RawSection>,
1193    current_lines: &mut Vec<String>,
1194    sections: &mut Vec<RawSection>,
1195) {
1196    let Some(mut section) = current.take() else {
1197        current_lines.clear();
1198        return;
1199    };
1200    let has_ops = current_lines.iter().any(|l| !l.trim().is_empty());
1201    if has_ops {
1202        section.diff = current_lines.join("\n");
1203        sections.push(section);
1204    }
1205    current_lines.clear();
1206}
1207
1208/// Split an authored patch into raw sections (path + hash + diff body).
1209/// Mirrors omp `splitRawSections`.
1210fn split_raw_sections(input: &str, cwd: Option<&Path>) -> Result<Vec<RawSection>, HashlineError> {
1211    let stripped = strip_leading_blank_lines(input);
1212    let lines = split_hashline_lines(&stripped);
1213
1214    let first_line = lines.first().map(String::as_str).unwrap_or("");
1215    if parse_hashline_header_line(first_line, cwd)?.is_none() {
1216        let first_trimmed = first_line.trim_end();
1217        if is_unified_diff_hunk(first_trimmed) {
1218            return Err(HashlineError::parse(
1219                1,
1220                "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. File \
1221                 sections start with `[path#HASH]`; use `replace`, `delete`, or `insert` ops."
1222                    .to_string(),
1223            ));
1224        }
1225        let preview = json_truncated(first_line, 120);
1226        let example = format!(
1227            "{p}src/foo.ts{s}1A2B{e}",
1228            p = HL_FILE_PREFIX,
1229            s = HL_FILE_HASH_SEP,
1230            e = HL_FILE_SUFFIX,
1231        );
1232        return Err(HashlineError::parse(
1233            1,
1234            format!(
1235                "input must begin with `{p}PATH{s}HASH{e}` on the first non-blank line for \
1236                 anchored edits; got: {preview}. Example: `{example}` then edit ops.",
1237                p = HL_FILE_PREFIX,
1238                s = HL_FILE_HASH_SEP,
1239                e = HL_FILE_SUFFIX,
1240            ),
1241        ));
1242    }
1243
1244    let mut sections = Vec::new();
1245    let mut current: Option<RawSection> = None;
1246    let mut current_lines: Vec<String> = Vec::new();
1247
1248    for (idx, line) in lines.iter().enumerate() {
1249        let line_num = (idx + 1) as u32;
1250        let token = classify_line(line, line_num);
1251        if matches!(token, Token::EnvelopeEnd { .. } | Token::Abort { .. }) {
1252            break;
1253        }
1254        if matches!(token, Token::EnvelopeBegin { .. }) {
1255            continue;
1256        }
1257        if line.trim_end().starts_with(HL_FILE_PREFIX)
1258            && let Some(header) = parse_hashline_header_line(line, cwd)?
1259        {
1260            flush_section(&mut current, &mut current_lines, &mut sections);
1261            current = Some(header);
1262            continue;
1263        }
1264        current_lines.push(line.clone());
1265    }
1266    flush_section(&mut current, &mut current_lines, &mut sections);
1267    Ok(sections)
1268}
1269
1270/// Collapse consecutive/interleaved sections targeting the same path into one,
1271/// concatenating diff bodies. Conflicting snapshot tags error. Mirrors omp
1272/// `mergeSamePathSections`.
1273fn merge_same_path_sections(sections: Vec<RawSection>) -> Result<Vec<RawSection>, HashlineError> {
1274    let mut order: Vec<String> = Vec::new();
1275    let mut by_path: HashMap<String, (Option<String>, Vec<String>)> = HashMap::new();
1276    for section in sections {
1277        if !by_path.contains_key(&section.path) {
1278            order.push(section.path.clone());
1279            by_path.insert(section.path.clone(), (None, Vec::new()));
1280        }
1281        let entry = by_path.get_mut(&section.path).expect("just inserted");
1282        match (&entry.0, &section.file_hash) {
1283            (Some(existing), Some(new)) if existing != new => {
1284                return Err(HashlineError::parse(
1285                    0,
1286                    format!(
1287                        "Conflicting hashline snapshot tags for {path}: #{a} and #{b}. Re-read \
1288                         the file and retry with one current header.",
1289                        path = section.path,
1290                        a = existing,
1291                        b = new
1292                    ),
1293                ));
1294            }
1295            (None, Some(new)) => entry.0 = Some(new.clone()),
1296            _ => {}
1297        }
1298        entry.1.push(section.diff);
1299    }
1300    Ok(order
1301        .into_iter()
1302        .map(|path| {
1303            let (file_hash, diffs) = by_path.remove(&path).expect("path tracked in order");
1304            RawSection {
1305                path,
1306                file_hash,
1307                diff: diffs.join("\n"),
1308            }
1309        })
1310        .collect())
1311}
1312
1313// ── Public API ───────────────────────────────────────────────────────────
1314
1315/// One section of a parsed patch: a target file plus its eagerly-parsed edits.
1316#[derive(Debug, Clone)]
1317pub struct PatchSection {
1318    /// Resolved file path from the `[PATH#HASH]` header.
1319    pub file_path: String,
1320    /// 4-hex snapshot tag (empty when the header omitted one).
1321    pub file_hash: String,
1322    /// Parsed line edits for this section's diff body.
1323    pub edits: Vec<Edit>,
1324    /// Warnings emitted during parsing.
1325    pub warnings: Vec<String>,
1326}
1327
1328impl PatchSection {
1329    fn from_raw(raw: RawSection) -> Result<Self, HashlineError> {
1330        let (edits, warnings) = parse_patch(&raw.diff)?;
1331        Ok(PatchSection {
1332            file_path: raw.path,
1333            file_hash: raw.file_hash.unwrap_or_default(),
1334            edits,
1335            warnings,
1336        })
1337    }
1338
1339    /// Re-parse this section's diff body from `diff` (convenience for callers
1340    /// holding a raw body rather than going through [`split_patch_input`]).
1341    pub fn parse_diff(diff: &str) -> Result<(Vec<Edit>, Vec<String>), HashlineError> {
1342        parse_patch(diff)
1343    }
1344}
1345
1346/// A parsed hashline patch — zero or more [`PatchSection`]s, each rooted at a
1347/// `[PATH#HASH]` header.
1348#[derive(Debug, Clone)]
1349pub struct Patch {
1350    /// Sections in first-occurrence path order.
1351    pub sections: Vec<PatchSection>,
1352}
1353
1354/// Parse `text` into a [`Patch`]. Splits the `*** Begin Patch … *** End Patch`
1355/// envelope into `[PATH#HASH]` sections and eagerly parses each section's
1356/// edits.
1357///
1358/// `opts.root` resolves absolute header paths to a root-relative form; omit it
1359/// for paths as-authored.
1360pub fn split_patch_input(text: &str, opts: Option<SplitOptions>) -> Result<Patch, HashlineError> {
1361    let opts = opts.unwrap_or_default();
1362    let cwd = opts.root.as_deref();
1363    let raw = split_raw_sections(text, cwd)?;
1364    let merged = merge_same_path_sections(raw)?;
1365    let mut sections = Vec::with_capacity(merged.len());
1366    for section in merged {
1367        sections.push(PatchSection::from_raw(section)?);
1368    }
1369    Ok(Patch { sections })
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375    use crate::types::{Cursor, InsertMode};
1376
1377    fn insert_text(edit: &Edit) -> &str {
1378        match edit {
1379            Edit::Insert { text, .. } => text,
1380            _ => "",
1381        }
1382    }
1383
1384    #[test]
1385    fn parses_header_path_and_hash() {
1386        let patch = split_patch_input("[src/foo.ts#1A2B]\nSWAP 1.=2:\n+a\n+b\n", None).unwrap();
1387        assert_eq!(patch.sections.len(), 1);
1388        let s = &patch.sections[0];
1389        assert_eq!(s.file_path, "src/foo.ts");
1390        assert_eq!(s.file_hash, "1A2B");
1391        // one Replace → 2 inserts (before line 1) + 2 deletes (lines 1,2)
1392        assert_eq!(s.edits.len(), 4);
1393    }
1394
1395    #[test]
1396    fn header_without_hash_is_empty_string() {
1397        let patch = split_patch_input("[a.ts]\nINS.TAIL:\n+z\n", None).unwrap();
1398        assert_eq!(patch.sections[0].file_hash, "");
1399    }
1400
1401    #[test]
1402    fn envelope_markers_consumed() {
1403        let input = "*** Begin Patch\n[a.ts#1A2B]\nINS.TAIL:\n+z\n*** End Patch\n";
1404        let patch = split_patch_input(input, None).unwrap();
1405        assert_eq!(patch.sections[0].edits.len(), 1);
1406    }
1407
1408    #[test]
1409    fn abort_marker_terminates() {
1410        let input = "[a.ts#1A2B]\nINS.TAIL:\n+z\n*** Abort\n[b.ts#3C4D]\nINS.TAIL:\n+q\n";
1411        let patch = split_patch_input(input, None).unwrap();
1412        assert_eq!(patch.sections.len(), 1);
1413    }
1414
1415    #[test]
1416    fn swap_lowers_to_replacement_inserts_and_deletes() {
1417        let (edits, _) = parse_patch("SWAP 5.=7:\n+x\n+y\n").unwrap();
1418        let inserts: Vec<_> = edits
1419            .iter()
1420            .filter(|e| matches!(e, Edit::Insert { .. }))
1421            .collect();
1422        let deletes: Vec<_> = edits
1423            .iter()
1424            .filter(|e| matches!(e, Edit::Delete { .. }))
1425            .collect();
1426        assert_eq!(inserts.len(), 2);
1427        assert_eq!(deletes.len(), 3); // lines 5,6,7
1428        // inserts land before the range start with Replacement mode
1429        for e in &inserts {
1430            match e {
1431                Edit::Insert { cursor, mode, .. } => {
1432                    assert!(matches!(cursor, Cursor::BeforeAnchor(_)));
1433                    assert_eq!(*mode, Some(InsertMode::Replacement));
1434                }
1435                _ => unreachable!(),
1436            }
1437        }
1438    }
1439
1440    #[test]
1441    fn swap_empty_range_becomes_delete() {
1442        let (edits, _) = parse_patch("SWAP 3.=4:\n").unwrap();
1443        assert!(edits.iter().all(|e| matches!(e, Edit::Delete { .. })));
1444        assert_eq!(edits.len(), 2);
1445    }
1446
1447    #[test]
1448    fn del_produces_only_deletes() {
1449        let (edits, _) = parse_patch("DEL 3.=5\n").unwrap();
1450        assert!(edits.iter().all(|e| matches!(e, Edit::Delete { .. })));
1451        assert_eq!(edits.len(), 3);
1452    }
1453
1454    #[test]
1455    fn del_rejects_body() {
1456        let err = parse_patch("DEL 3.=5\n+oops\n").unwrap_err();
1457        assert!(matches!(err, HashlineError::Parse { .. }));
1458    }
1459
1460    #[test]
1461    fn ins_variants_parse() {
1462        let (edits, _) = parse_patch("INS.PRE 2:\n+a\n").unwrap();
1463        assert_eq!(edits.len(), 1);
1464        assert!(matches!(
1465            edits[0],
1466            Edit::Insert { cursor: Cursor::BeforeAnchor(_), ref text, .. } if text == "a"
1467        ));
1468
1469        let (edits, _) = parse_patch("INS.POST 2:\n+a\n").unwrap();
1470        assert!(matches!(
1471            edits[0],
1472            Edit::Insert {
1473                cursor: Cursor::AfterAnchor(_),
1474                ..
1475            }
1476        ));
1477
1478        let (edits, _) = parse_patch("INS.HEAD:\n+a\n").unwrap();
1479        assert!(matches!(
1480            edits[0],
1481            Edit::Insert {
1482                cursor: Cursor::Bof,
1483                ..
1484            }
1485        ));
1486
1487        let (edits, _) = parse_patch("INS.TAIL:\n+a\n").unwrap();
1488        assert!(matches!(
1489            edits[0],
1490            Edit::Insert {
1491                cursor: Cursor::Eof,
1492                ..
1493            }
1494        ));
1495    }
1496
1497    #[test]
1498    fn ins_requires_body() {
1499        assert!(parse_patch("INS.HEAD:\n").is_err());
1500    }
1501
1502    #[test]
1503    fn payload_literal_preserved_verbatim() {
1504        let (edits, _) = parse_patch("INS.TAIL:\n+  indented\n+\n+-dash-prefixed\n").unwrap();
1505        assert_eq!(insert_text(&edits[0]), "  indented");
1506        assert_eq!(insert_text(&edits[1]), "");
1507        assert_eq!(insert_text(&edits[2]), "-dash-prefixed");
1508    }
1509
1510    #[test]
1511    fn bare_body_auto_piped_warning() {
1512        let (edits, warnings) = parse_patch("INS.TAIL:\nhello\n").unwrap();
1513        assert_eq!(insert_text(&edits[0]), "hello");
1514        assert!(warnings.iter().any(|w| w == BARE_BODY_AUTO_PIPED_WARNING));
1515    }
1516
1517    #[test]
1518    fn minus_row_rejected() {
1519        let err = parse_patch("INS.TAIL:\n-bad\n").unwrap_err();
1520        let HashlineError::Parse { msg, .. } = err else {
1521            panic!("expected Parse error");
1522        };
1523        assert!(msg.contains("not valid"));
1524    }
1525
1526    #[test]
1527    fn contamination_apply_patch_sentinel() {
1528        let msg = detect_apply_patch_contamination("*** Update File: foo", false);
1529        assert!(msg.is_some());
1530        assert!(msg.unwrap().contains("apply_patch sentinel"));
1531    }
1532
1533    #[test]
1534    fn contamination_unified_diff_hunk() {
1535        let msg = detect_apply_patch_contamination("@@ -1,3 +1,3 @@", false);
1536        assert!(msg.unwrap().contains("unified-diff hunk header"));
1537    }
1538
1539    #[test]
1540    fn contamination_bare_range() {
1541        let msg = detect_apply_patch_contamination("5.=7:", false);
1542        assert!(msg.unwrap().contains("bare range hunk header"));
1543    }
1544
1545    #[test]
1546    fn contamination_bare_line_number() {
1547        let msg = detect_apply_patch_contamination("42", false);
1548        assert!(msg.unwrap().contains("hunk headers need a verb"));
1549    }
1550
1551    #[test]
1552    fn contamination_del_with_colon() {
1553        let msg = detect_apply_patch_contamination("DEL 3.=7:", false);
1554        assert!(msg.unwrap().contains("no colon and no body"));
1555    }
1556
1557    #[test]
1558    fn first_line_not_header_errors() {
1559        let err = split_patch_input("SWAP 1.=2:\n+a\n", None).unwrap_err();
1560        assert!(matches!(err, HashlineError::Parse { .. }));
1561    }
1562
1563    #[test]
1564    fn first_line_unified_diff_errors() {
1565        let err = split_patch_input("@@ -1,3 +1,3 @@\n+a\n", None).unwrap_err();
1566        let HashlineError::Parse { msg, .. } = err else {
1567            panic!("expected Parse");
1568        };
1569        assert!(msg.contains("unified-diff"));
1570    }
1571
1572    #[test]
1573    fn conflicting_hashes_error() {
1574        let input = "[a.ts#1A2B]\nINS.TAIL:\n+x\n[a.ts#3C4D]\nINS.TAIL:\n+y\n";
1575        let err = split_patch_input(input, None).unwrap_err();
1576        let HashlineError::Parse { msg, .. } = err else {
1577            panic!("expected Parse");
1578        };
1579        assert!(msg.contains("Conflicting hashline snapshot tags"));
1580    }
1581
1582    #[test]
1583    fn merge_same_path_sections() {
1584        let input = "[a.ts#1A2B]\nINS.TAIL:\n+x\n[a.ts#1A2B]\nINS.TAIL:\n+y\n";
1585        let patch = split_patch_input(input, None).unwrap();
1586        assert_eq!(patch.sections.len(), 1);
1587        assert_eq!(patch.sections[0].edits.len(), 2);
1588    }
1589
1590    #[test]
1591    fn multiple_sections() {
1592        let input = "[a.ts#1A2B]\nINS.TAIL:\n+x\n[b.ts#3C4D]\nDEL 1\n";
1593        let patch = split_patch_input(input, None).unwrap();
1594        assert_eq!(patch.sections.len(), 2);
1595        assert_eq!(patch.sections[0].file_path, "a.ts");
1596        assert_eq!(patch.sections[1].file_path, "b.ts");
1597        assert_eq!(patch.sections[1].edits.len(), 1);
1598    }
1599
1600    #[test]
1601    fn skippable_comment_between_hunks() {
1602        let input = "[a.ts#1A2B]\nINS.TAIL:\n+x\n# a comment\nINS.HEAD:\n+y\n";
1603        let patch = split_patch_input(input, None).unwrap();
1604        // comment consumed; two inserts present
1605        let s = &patch.sections[0];
1606        assert!(
1607            s.edits
1608                .iter()
1609                .any(|e| matches!(e, Edit::Insert { text, .. } if text == "x"))
1610        );
1611        assert!(
1612            s.edits
1613                .iter()
1614                .any(|e| matches!(e, Edit::Insert { text, .. } if text == "y"))
1615        );
1616    }
1617
1618    #[test]
1619    fn strip_one_prefix() {
1620        assert_eq!(strip_one_leading_hashline_prefix("42:hello"), "hello");
1621        assert_eq!(strip_one_leading_hashline_prefix(">>>42:hi"), "hi");
1622        assert_eq!(strip_one_leading_hashline_prefix("+5:yo"), "yo");
1623        assert_eq!(strip_one_leading_hashline_prefix("hello"), "hello");
1624        assert_eq!(strip_one_leading_hashline_prefix("12:30"), "30"); // \d+: strips the first "12:" prefix (timestamp edge case)
1625    }
1626
1627    #[test]
1628    fn strip_prefix_uniform() {
1629        // All bare rows carry N: prefix → stripped.
1630        let diff = "INS.TAIL:\n1:a\n2:b\n";
1631        let (edits, _) = parse_patch(diff).unwrap();
1632        assert_eq!(insert_text(&edits[0]), "a");
1633        assert_eq!(insert_text(&edits[1]), "b");
1634    }
1635
1636    #[test]
1637    fn strip_prefix_not_uniform_keeps() {
1638        // Mixed: one prefixed, one not → keep both as-is.
1639        let diff = "INS.TAIL:\n1:a\nb\n";
1640        let (edits, _) = parse_patch(diff).unwrap();
1641        assert_eq!(insert_text(&edits[0]), "1:a");
1642        assert_eq!(insert_text(&edits[1]), "b");
1643    }
1644
1645    #[test]
1646    fn quoted_path_unquoted() {
1647        let patch = split_patch_input("[\"src/foo.ts\"#1A2B]\nINS.TAIL:\n+z\n", None).unwrap();
1648        assert_eq!(patch.sections[0].file_path, "src/foo.ts");
1649    }
1650
1651    #[test]
1652    fn recovery_header_strips_noise() {
1653        let patch =
1654            split_patch_input("[*** Update File:src/foo.ts#1A2B]\nINS.TAIL:\n+z\n", None).unwrap();
1655        assert_eq!(patch.sections[0].file_path, "src/foo.ts");
1656    }
1657
1658    #[test]
1659    fn overlapping_deletes_error() {
1660        let err = parse_patch("DEL 5.=7\nDEL 6\n").unwrap_err();
1661        let HashlineError::Parse { msg, .. } = err else {
1662            panic!("expected Parse");
1663        };
1664        assert!(msg.contains("already targeted"));
1665    }
1666
1667    #[test]
1668    fn range_order_validated() {
1669        let err = parse_patch("SWAP 7.=3:\n+x\n").unwrap_err();
1670        assert!(matches!(err, HashlineError::Parse { .. }));
1671    }
1672}