1use crate::format::{
13 HL_DELETE_KEYWORD, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX,
14 HL_HEADER_COLON, HL_INSERT_AFTER, HL_INSERT_BEFORE, HL_INSERT_HEAD, HL_INSERT_KEYWORD,
15 HL_INSERT_TAIL, HL_PAYLOAD_REPLACE, HL_REPLACE_KEYWORD,
16};
17use crate::messages::{ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER};
18use crate::mismatch::HashlineError;
19use crate::types::{Anchor, Cursor, ParsedRange};
20
21#[inline]
24fn is_digit(b: u8) -> bool {
25 b.is_ascii_digit()
26}
27#[inline]
28fn is_nonzero_digit(b: u8) -> bool {
29 (b'1'..=b'9').contains(&b)
30}
31#[inline]
32fn is_hex_digit(b: u8) -> bool {
33 b.is_ascii_hexdigit()
34}
35#[inline]
37fn is_ws(b: u8) -> bool {
38 b == b' ' || (b'\t'..=b'\r').contains(&b)
39}
40
41fn skip_ws(bytes: &[u8], mut idx: usize, end: usize) -> usize {
42 while idx < end && is_ws(bytes[idx]) {
43 idx += 1;
44 }
45 idx
46}
47
48fn trim_end(bytes: &[u8]) -> usize {
50 let mut end = bytes.len();
51 while end > 0 && is_ws(bytes[end - 1]) {
52 end -= 1;
53 }
54 end
55}
56
57fn marker_line_equals(line: &str, marker: &str) -> bool {
58 let bytes = line.as_bytes();
59 let end = trim_end(bytes);
60 end == marker.len() && bytes[..end] == *marker.as_bytes()
61}
62
63pub fn split_hashline_lines(text: &str) -> Vec<String> {
68 if text.is_empty() {
69 return vec![String::new()];
70 }
71 let bytes = text.as_bytes();
72 let mut lines = Vec::new();
73 let mut start = 0usize;
74 for (i, &b) in bytes.iter().enumerate() {
75 if b != b'\n' {
76 continue;
77 }
78 let mut stop = i;
79 if stop > start && bytes[stop - 1] == b'\r' {
80 stop -= 1;
81 }
82 lines.push(text[start..stop].to_string());
83 start = i + 1;
84 }
85 if start < bytes.len() {
86 let mut stop = bytes.len();
87 if stop > start && bytes[stop - 1] == b'\r' {
88 stop -= 1;
89 }
90 lines.push(text[start..stop].to_string());
91 }
92 lines
93}
94
95pub fn clone_cursor(cursor: &Cursor) -> Cursor {
99 cursor.clone()
100}
101
102struct NumberScan {
105 line: u32,
106 next: usize,
107}
108
109fn scan_line_number(bytes: &[u8], idx: usize, end: usize) -> Option<NumberScan> {
110 if idx >= end || !is_nonzero_digit(bytes[idx]) {
111 return None;
112 }
113 let mut line: u32 = 0;
114 let mut next = idx;
115 while next < end && is_digit(bytes[next]) {
116 line = line
117 .checked_mul(10)?
118 .checked_add((bytes[next] - b'0') as u32)?;
119 next += 1;
120 }
121 Some(NumberScan { line, next })
122}
123
124pub fn parse_lid(raw: &str, line_num: u32) -> Result<Anchor, HashlineError> {
126 let bytes = raw.as_bytes();
127 let end = trim_end(bytes);
128 let number_start = skip_ws(bytes, 0, end);
129 let number = scan_line_number(bytes, number_start, end)
130 .ok_or_else(|| HashlineError::parse(line_num, expected_lid_message(raw)))?;
131 if skip_ws(bytes, number.next, end) != end {
132 return Err(HashlineError::parse(line_num, expected_lid_message(raw)));
133 }
134 Ok(Anchor { line: number.line })
135}
136
137fn expected_lid_message(raw: &str) -> String {
138 format!(
139 "expected a line number such as {examples}; got `{raw}`. \
140 Use `{p}PATH{s}hash{e}` from your latest read for file-version binding.",
141 examples = crate::messages::describe_anchor_examples("119"),
142 p = HL_FILE_PREFIX,
143 s = HL_FILE_HASH_SEP,
144 e = HL_FILE_SUFFIX,
145 )
146}
147
148struct RangeScan {
149 range: ParsedRange,
150 next: usize,
151}
152
153fn scan_range_separator(bytes: &[u8], start: usize, end: usize) -> Option<usize> {
156 let mut cursor = start;
157 let mut consumed = false;
158 let ellipsis = "\u{2026}".as_bytes(); while cursor < end {
160 let b = bytes[cursor];
161 if is_ws(b) {
162 cursor += 1;
163 consumed = true;
164 continue;
165 }
166 if b == b'-' {
167 cursor += 1;
168 consumed = true;
169 continue;
170 }
171 if bytes[cursor..end].starts_with(ellipsis) {
172 cursor += ellipsis.len();
173 consumed = true;
174 continue;
175 }
176 if b == b'.' && cursor + 1 < end && (bytes[cursor + 1] == b'.' || bytes[cursor + 1] == b'=')
177 {
178 cursor += 2;
179 consumed = true;
180 continue;
181 }
182 break;
183 }
184 if !consumed {
185 return None;
186 }
187 if cursor >= end || !is_nonzero_digit(bytes[cursor]) {
188 return None;
189 }
190 Some(cursor)
191}
192
193fn scan_header_range(
195 bytes: &[u8],
196 idx: usize,
197 end: usize,
198 allow_single: bool,
199) -> Option<RangeScan> {
200 let number_start = skip_ws(bytes, idx, end);
201 let start = scan_line_number(bytes, number_start, end)?;
202 match scan_range_separator(bytes, start.next, end) {
203 None => {
204 if !allow_single {
205 return None;
206 }
207 Some(RangeScan {
208 range: ParsedRange {
209 start: start.line,
210 end: start.line,
211 },
212 next: skip_ws(bytes, start.next, end),
213 })
214 }
215 Some(after_first) => {
216 let end_num = scan_line_number(bytes, after_first, end)?;
217 Some(RangeScan {
218 range: ParsedRange {
219 start: start.line,
220 end: end_num.line,
221 },
222 next: skip_ws(bytes, end_num.next, end),
223 })
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum BlockTarget {
233 Replace {
235 range: ParsedRange,
237 },
238 Delete {
240 range: ParsedRange,
242 },
243 InsertBefore {
245 anchor: Anchor,
247 },
248 InsertAfter {
250 anchor: Anchor,
252 },
253 Bof,
255 Eof,
257}
258
259struct TargetScan {
260 target: BlockTarget,
261 next: usize,
262}
263
264fn scan_keyword(bytes: &[u8], idx: usize, end: usize, keyword: &[u8]) -> Option<usize> {
267 if !bytes[idx..end].starts_with(keyword) {
268 return None;
269 }
270 let next = idx + keyword.len();
271 if next < end {
272 let b = bytes[next];
273 if !is_ws(b) && b != HL_HEADER_COLON as u8 && b != b'.' {
274 return None;
275 }
276 }
277 Some(next)
278}
279
280fn consume_optional_colon(bytes: &[u8], idx: usize, end: usize) -> usize {
282 let cursor = skip_ws(bytes, idx, end);
283 if cursor < end && bytes[cursor] == HL_HEADER_COLON as u8 {
284 skip_ws(bytes, cursor + 1, end)
285 } else {
286 cursor
287 }
288}
289
290fn scan_insert_target(bytes: &[u8], idx: usize, end: usize) -> Option<TargetScan> {
292 if idx >= end || bytes[idx] != b'.' {
293 return None;
294 }
295 let cursor = skip_ws(bytes, idx + 1, end);
296 if let Some(e) = scan_keyword(bytes, cursor, end, HL_INSERT_BEFORE.as_bytes()) {
297 let anchor = scan_line_number(bytes, skip_ws(bytes, e, end), end)?;
298 return Some(TargetScan {
299 target: BlockTarget::InsertBefore {
300 anchor: Anchor { line: anchor.line },
301 },
302 next: consume_optional_colon(bytes, anchor.next, end),
303 });
304 }
305 if let Some(e) = scan_keyword(bytes, cursor, end, HL_INSERT_AFTER.as_bytes()) {
306 let anchor = scan_line_number(bytes, skip_ws(bytes, e, end), end)?;
307 return Some(TargetScan {
308 target: BlockTarget::InsertAfter {
309 anchor: Anchor { line: anchor.line },
310 },
311 next: consume_optional_colon(bytes, anchor.next, end),
312 });
313 }
314 if let Some(e) = scan_keyword(bytes, cursor, end, HL_INSERT_HEAD.as_bytes()) {
315 return Some(TargetScan {
316 target: BlockTarget::Bof,
317 next: consume_optional_colon(bytes, e, end),
318 });
319 }
320 if let Some(e) = scan_keyword(bytes, cursor, end, HL_INSERT_TAIL.as_bytes()) {
321 return Some(TargetScan {
322 target: BlockTarget::Eof,
323 next: consume_optional_colon(bytes, e, end),
324 });
325 }
326 None
327}
328
329fn scan_hunk_anchor(bytes: &[u8], start: usize, end: usize) -> Option<TargetScan> {
331 let cursor = skip_ws(bytes, start, end);
332
333 if let Some(e) = scan_keyword(bytes, cursor, end, HL_REPLACE_KEYWORD.as_bytes()) {
334 let range = scan_header_range(bytes, e, end, true)?;
335 return Some(TargetScan {
336 target: BlockTarget::Replace { range: range.range },
337 next: consume_optional_colon(bytes, range.next, end),
338 });
339 }
340 if let Some(e) = scan_keyword(bytes, cursor, end, HL_DELETE_KEYWORD.as_bytes()) {
341 let range = scan_header_range(bytes, e, end, true)?;
342 let next = skip_ws(bytes, range.next, end);
343 if next < end && bytes[next] == HL_HEADER_COLON as u8 {
345 return None;
346 }
347 return Some(TargetScan {
348 target: BlockTarget::Delete { range: range.range },
349 next,
350 });
351 }
352 if let Some(e) = scan_keyword(bytes, cursor, end, HL_INSERT_KEYWORD.as_bytes()) {
353 return scan_insert_target(bytes, e, end);
354 }
355 None
356}
357
358fn try_parse_hunk_header(line: &str) -> Option<BlockTarget> {
359 let bytes = line.as_bytes();
360 let end = trim_end(bytes);
361 let start = skip_ws(bytes, 0, end);
362 if start >= end {
363 return None;
364 }
365 let scan = scan_hunk_anchor(bytes, start, end)?;
366 if scan.next != end {
367 return None;
368 }
369 Some(scan.target)
370}
371
372struct HeaderScan {
375 path: String,
376 file_hash: Option<String>,
377}
378
379fn try_parse_header(line: &str) -> Option<HeaderScan> {
383 let bytes = line.as_bytes();
384 if !bytes.starts_with(HL_FILE_PREFIX.as_bytes()) {
385 return None;
386 }
387 let end = trim_end(bytes);
388 if HL_FILE_PREFIX.len() + HL_FILE_SUFFIX.len() >= end {
389 return None;
390 }
391 if !bytes[..end].ends_with(HL_FILE_SUFFIX.as_bytes()) {
392 return None;
393 }
394 let body_end = end - HL_FILE_SUFFIX.len();
395 if HL_FILE_PREFIX.len() >= body_end {
396 return None;
397 }
398
399 let mut path_end = body_end;
402 let mut file_hash = None;
403 let trailing_hash_start = body_end.saturating_sub(HL_FILE_HASH_LENGTH + 1);
404 if trailing_hash_start >= HL_FILE_PREFIX.len() && bytes[trailing_hash_start] == b'#' {
405 let mut all_hex = true;
406 for &byte in &bytes[(trailing_hash_start + 1)..body_end] {
407 if !is_hex_digit(byte) {
408 all_hex = false;
409 break;
410 }
411 }
412 if all_hex {
413 path_end = trailing_hash_start;
414 file_hash = Some(line[(trailing_hash_start + 1)..body_end].to_uppercase());
416 }
417 }
418
419 for &byte in &bytes[HL_FILE_PREFIX.len()..path_end] {
421 if byte == b'#' {
422 return None;
423 }
424 }
425 if path_end == HL_FILE_PREFIX.len() {
426 return None;
427 }
428 let path = line[HL_FILE_PREFIX.len()..path_end].to_string();
429 Some(HeaderScan { path, file_hash })
430}
431
432#[derive(Debug, Clone)]
436pub enum Token {
437 Blank {
439 line_num: u32,
441 },
442 EnvelopeBegin {
444 line_num: u32,
446 },
447 EnvelopeEnd {
449 line_num: u32,
451 },
452 Abort {
454 line_num: u32,
456 },
457 Header {
459 line_num: u32,
461 path: String,
463 file_hash: Option<String>,
465 },
466 Op {
468 line_num: u32,
470 target: BlockTarget,
472 },
473 PayloadLiteral {
475 line_num: u32,
477 text: String,
479 },
480 Raw {
482 line_num: u32,
484 text: String,
486 },
487}
488
489impl Token {
490 pub fn line_num(&self) -> u32 {
492 match self {
493 Token::Blank { line_num }
494 | Token::EnvelopeBegin { line_num }
495 | Token::EnvelopeEnd { line_num }
496 | Token::Abort { line_num }
497 | Token::Header { line_num, .. }
498 | Token::Op { line_num, .. }
499 | Token::PayloadLiteral { line_num, .. }
500 | Token::Raw { line_num, .. } => *line_num,
501 }
502 }
503}
504
505pub(crate) fn classify_line(line: &str, line_num: u32) -> Token {
506 if line.is_empty() {
507 return Token::Blank { line_num };
508 }
509 if marker_line_equals(line, BEGIN_PATCH_MARKER) {
510 return Token::EnvelopeBegin { line_num };
511 }
512 if marker_line_equals(line, END_PATCH_MARKER) {
513 return Token::EnvelopeEnd { line_num };
514 }
515 if marker_line_equals(line, ABORT_MARKER) {
516 return Token::Abort { line_num };
517 }
518 if line.starts_with(HL_FILE_PREFIX)
519 && let Some(header) = try_parse_header(line)
520 {
521 return Token::Header {
522 line_num,
523 path: header.path,
524 file_hash: header.file_hash,
525 };
526 }
527 let bytes = line.as_bytes();
528 let lead = skip_ws(bytes, 0, bytes.len());
529 let is_hunk_lead = line[lead..].starts_with(HL_REPLACE_KEYWORD)
530 || line[lead..].starts_with(HL_DELETE_KEYWORD)
531 || line[lead..].starts_with(HL_INSERT_KEYWORD);
532 if is_hunk_lead && let Some(target) = try_parse_hunk_header(line) {
533 return Token::Op { line_num, target };
534 }
535 if bytes.first().copied() == Some(HL_PAYLOAD_REPLACE as u8) {
536 return Token::PayloadLiteral {
537 line_num,
538 text: line[1..].to_string(),
539 };
540 }
541 Token::Raw {
542 line_num,
543 text: line.to_string(),
544 }
545}
546
547#[derive(Debug)]
556pub struct Tokenizer {
557 buffer: String,
558 next_line_num: u32,
559 closed: bool,
560}
561
562impl Default for Tokenizer {
563 fn default() -> Self {
564 Self::new()
565 }
566}
567
568impl Tokenizer {
569 pub fn new() -> Self {
571 Self {
572 buffer: String::new(),
573 next_line_num: 1,
574 closed: false,
575 }
576 }
577
578 pub fn feed(&mut self, chunk: &str) -> Vec<Token> {
582 if self.closed || chunk.is_empty() {
583 return Vec::new();
584 }
585 self.buffer.push_str(chunk);
586 self.drain_complete_lines()
587 }
588
589 pub fn end(&mut self) -> Vec<Token> {
591 if self.closed {
592 return Vec::new();
593 }
594 self.closed = true;
595 if self.buffer.is_empty() {
596 return Vec::new();
597 }
598 let bytes = self.buffer.as_bytes();
599 let mut stop = bytes.len();
600 if stop > 0 && bytes[stop - 1] == b'\r' {
601 stop -= 1;
602 }
603 let token = classify_line(&self.buffer[..stop], self.next_line_num);
604 self.next_line_num = self.next_line_num.wrapping_add(1);
605 self.buffer.clear();
606 vec![token]
607 }
608
609 pub fn reset(&mut self) {
611 self.buffer.clear();
612 self.next_line_num = 1;
613 self.closed = false;
614 }
615
616 pub fn tokenize_all(&mut self, text: &str) -> Vec<Token> {
618 self.reset();
619 let mut tokens = self.feed(text);
620 tokens.extend(self.end());
621 tokens
622 }
623
624 pub fn tokenize(&self, line: &str, line_num: u32) -> Token {
626 classify_line(line, line_num)
627 }
628
629 pub fn is_op(&self, line: &str) -> bool {
631 try_parse_hunk_header(line).is_some()
632 }
633
634 pub fn is_header(&self, line: &str) -> bool {
636 try_parse_header(line).is_some()
637 }
638
639 pub fn is_envelope_marker(&self, line: &str) -> bool {
641 marker_line_equals(line, BEGIN_PATCH_MARKER)
642 || marker_line_equals(line, END_PATCH_MARKER)
643 || marker_line_equals(line, ABORT_MARKER)
644 }
645
646 fn drain_complete_lines(&mut self) -> Vec<Token> {
647 let bytes = self.buffer.as_bytes();
648 let mut tokens = Vec::new();
649 let mut start = 0usize;
650 for (i, &b) in bytes.iter().enumerate() {
651 if b != b'\n' {
652 continue;
653 }
654 let mut stop = i;
655 if stop > start && bytes[stop - 1] == b'\r' {
656 stop -= 1;
657 }
658 let line = self.buffer[start..stop].to_string();
659 tokens.push(classify_line(&line, self.next_line_num));
660 self.next_line_num = self.next_line_num.wrapping_add(1);
661 start = i + 1;
662 }
663 if start == 0 {
664 return tokens;
665 }
666 let remainder = self.buffer.split_off(start);
668 self.buffer = remainder;
669 tokens
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn splits_lines_strips_cr() {
679 assert_eq!(split_hashline_lines(""), vec![""]);
680 assert_eq!(split_hashline_lines("a\nb"), vec!["a", "b"]);
681 assert_eq!(split_hashline_lines("a\r\nb\r\n"), vec!["a", "b"]);
682 assert_eq!(split_hashline_lines("a\nb"), vec!["a", "b"]);
683 assert_eq!(split_hashline_lines("a\nb\nc"), vec!["a", "b", "c"]);
685 }
686
687 #[test]
688 fn parses_header_with_hash() {
689 let h = try_parse_header("[src/foo.ts#1A2B]").unwrap();
690 assert_eq!(h.path, "src/foo.ts");
691 assert_eq!(h.file_hash.as_deref(), Some("1A2B"));
692 }
693
694 #[test]
695 fn parses_header_without_hash() {
696 let h = try_parse_header("[src/foo.ts]").unwrap();
697 assert_eq!(h.path, "src/foo.ts");
698 assert!(h.file_hash.is_none());
699 }
700
701 #[test]
702 fn uppercases_hex_tag() {
703 let h = try_parse_header("[a#1a2b]").unwrap();
704 assert_eq!(h.file_hash.as_deref(), Some("1A2B"));
705 }
706
707 #[test]
708 fn rejects_embedded_hash_in_path() {
709 assert!(try_parse_header("[a#1A2#b]").is_none());
710 assert!(try_parse_header("[a#1A2G]").is_none()); assert!(try_parse_header("[a#1A2]").is_none()); assert!(try_parse_header("[a#1A2B5]").is_none()); }
714
715 #[test]
716 fn path_with_spaces_ok() {
717 let h = try_parse_header("[OneDrive - Co/x.ts#1A2B]").unwrap();
718 assert_eq!(h.path, "OneDrive - Co/x.ts");
719 }
720
721 #[test]
722 fn parses_swap_range() {
723 let t = try_parse_hunk_header("SWAP 5.=10:").unwrap();
724 assert_eq!(
725 t,
726 BlockTarget::Replace {
727 range: ParsedRange { start: 5, end: 10 }
728 }
729 );
730 }
731
732 #[test]
733 fn parses_swap_single() {
734 let t = try_parse_hunk_header("SWAP 5:").unwrap();
735 assert_eq!(
736 t,
737 BlockTarget::Replace {
738 range: ParsedRange { start: 5, end: 5 }
739 }
740 );
741 }
742
743 #[test]
744 fn parses_delete_range_and_single() {
745 assert_eq!(
746 try_parse_hunk_header("DEL 3.=7"),
747 Some(BlockTarget::Delete {
748 range: ParsedRange { start: 3, end: 7 }
749 })
750 );
751 assert_eq!(
752 try_parse_hunk_header("DEL 3"),
753 Some(BlockTarget::Delete {
754 range: ParsedRange { start: 3, end: 3 }
755 })
756 );
757 }
758
759 #[test]
760 fn delete_rejects_colon() {
761 assert!(try_parse_hunk_header("DEL 3.=7:").is_none());
762 }
763
764 #[test]
765 fn parses_insert_variants() {
766 assert_eq!(
767 try_parse_hunk_header("INS.PRE 5:"),
768 Some(BlockTarget::InsertBefore {
769 anchor: Anchor { line: 5 }
770 })
771 );
772 assert_eq!(
773 try_parse_hunk_header("INS.POST 5:"),
774 Some(BlockTarget::InsertAfter {
775 anchor: Anchor { line: 5 }
776 })
777 );
778 assert_eq!(try_parse_hunk_header("INS.HEAD:"), Some(BlockTarget::Bof));
779 assert_eq!(try_parse_hunk_header("INS.TAIL:"), Some(BlockTarget::Eof));
780 }
781
782 #[test]
783 fn classify_envelope_and_payload() {
784 assert!(matches!(
785 classify_line("*** Begin Patch", 1),
786 Token::EnvelopeBegin { .. }
787 ));
788 assert!(matches!(
789 classify_line("*** End Patch", 1),
790 Token::EnvelopeEnd { .. }
791 ));
792 assert!(matches!(classify_line("*** Abort", 1), Token::Abort { .. }));
793 assert!(matches!(
794 classify_line("+hello", 1),
795 Token::PayloadLiteral { text, .. } if text == "hello"
796 ));
797 assert!(matches!(classify_line("", 1), Token::Blank { .. }));
798 assert!(matches!(
799 classify_line("# comment", 1),
800 Token::Raw { text, .. } if text == "# comment"
801 ));
802 }
803
804 #[test]
805 fn tokenize_all_full_flow() {
806 let mut tok = Tokenizer::new();
807 let toks = tok.tokenize_all("[a.ts#1A2B]\nSWAP 1.=2:\n+x\n");
808 assert_eq!(toks.len(), 3);
809 assert!(matches!(toks[0], Token::Header { .. }));
810 assert!(matches!(toks[1], Token::Op { .. }));
811 assert!(matches!(toks[2], Token::PayloadLiteral { .. }));
812 assert_eq!(toks[0].line_num(), 1);
814 assert_eq!(toks[1].line_num(), 2);
815 assert_eq!(toks[2].line_num(), 3);
816 }
817
818 #[test]
819 fn parse_lid_valid_and_invalid() {
820 assert_eq!(parse_lid("42", 1).unwrap(), Anchor { line: 42 });
821 assert!(parse_lid("x", 1).is_err());
822 assert!(parse_lid("42x", 1).is_err());
823 }
824}