1use 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#[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#[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
74fn 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
113fn 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; }
132 i += 1; 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
167fn 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; }
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
196fn 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
255fn 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
294fn 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 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
352fn 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
368fn 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
483fn 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#[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
530pub(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 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 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 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
876pub 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
889pub 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#[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
963fn 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
994fn 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
1015fn 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
1048fn 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
1068fn 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
1090fn 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
1117fn 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
1169fn 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
1208fn 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
1270fn 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(§ion.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(§ion.path).expect("just inserted");
1282 match (&entry.0, §ion.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#[derive(Debug, Clone)]
1317pub struct PatchSection {
1318 pub file_path: String,
1320 pub file_hash: String,
1322 pub edits: Vec<Edit>,
1324 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 pub fn parse_diff(diff: &str) -> Result<(Vec<Edit>, Vec<String>), HashlineError> {
1342 parse_patch(diff)
1343 }
1344}
1345
1346#[derive(Debug, Clone)]
1349pub struct Patch {
1350 pub sections: Vec<PatchSection>,
1352}
1353
1354pub 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 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); 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 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"); }
1626
1627 #[test]
1628 fn strip_prefix_uniform() {
1629 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 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}