Skip to main content

oxicode_hashline/
tokenizer.rs

1//! Stateful, line-oriented classifier for hashline diff text.
2//!
3//! Turns raw patch lines into typed [`Token`]s (section headers, hunk
4//! headers, payload rows). The parser consumes the token stream.
5//!
6//! Line-ops only (default build): `SWAP`, `DEL`, `INS.PRE|POST|HEAD|TAIL`.
7//! Block ops (`SWAP.BLK`, `DEL.BLK`, `INS.BLK.POST`) live behind the
8//! `block-ops` feature gate and are not recognized here.
9//!
10//! Ported from omp `packages/hashline/src/tokenizer.ts`.
11
12use 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// ── Byte-level predicates ────────────────────────────────────────────────
22
23#[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/// omp `isWhitespaceCode`: space, or 0x09–0x0d (tab, LF, VT, FF, CR).
36#[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
48/// Index of the first non-trailing-whitespace byte (`bytes.len()` if all ws).
49fn 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
63// ── Line splitting ───────────────────────────────────────────────────────
64
65/// Split `text` into lines on `\n`, stripping a trailing `\r` from each.
66/// An empty input yields a single empty line (omp `splitHashlineLines`).
67pub 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
95/// `Cursor` carries only a `Copy` [`Anchor`]; cloning is cheap. Provided for
96/// API parity with omp `cloneCursor` — idiomatic Rust callers reach for
97/// `Cursor::clone` directly.
98pub fn clone_cursor(cursor: &Cursor) -> Cursor {
99    cursor.clone()
100}
101
102// ── Number / range scanning ──────────────────────────────────────────────
103
104struct 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
124/// Parse a bare line-number anchor. Errors on malformed input.
125pub 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
153/// Scan the range separator (`..`, `.=`, `-`, `…`, or whitespace) between two
154/// line numbers. Returns the index where the end-number begins.
155fn 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(); // …  (U+2026, 3 UTF-8 bytes)
159    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
193/// Parse a `start.=end` range; with `allow_single` a bare `N` yields `{N, N}`.
194fn 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// ── Hunk anchor scanning ─────────────────────────────────────────────────
229
230/// Where a hunk header lands. Line-ops only in the default build.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum BlockTarget {
233    /// `SWAP start.=end:` — replace the inclusive range.
234    Replace {
235        /// Inclusive source line range overwritten by the hunk's body.
236        range: ParsedRange,
237    },
238    /// `DEL start` or `DEL start.=end` — delete the inclusive range (no body).
239    Delete {
240        /// Inclusive source line range removed by the hunk.
241        range: ParsedRange,
242    },
243    /// `INS.PRE N:` — insert before line N.
244    InsertBefore {
245        /// Anchor line that the hunk's body rows precede.
246        anchor: Anchor,
247    },
248    /// `INS.POST N:` — insert after line N.
249    InsertAfter {
250        /// Anchor line that the hunk's body rows follow.
251        anchor: Anchor,
252    },
253    /// `INS.HEAD:` — insert at the very top.
254    Bof,
255    /// `INS.TAIL:` — insert at the very bottom.
256    Eof,
257}
258
259struct TargetScan {
260    target: BlockTarget,
261    next: usize,
262}
263
264/// Match a keyword at `idx`; the byte after it must be ws, `:`, or `.` (so
265/// `SWAP` does not match `SWAPPER`).
266fn 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
280/// Skip optional trailing whitespace + colon + whitespace.
281fn 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
290/// Parse the `.PRE N` / `.POST N` / `.HEAD` / `.TAIL` tail of an `INS` header.
291fn 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
329/// Parse the verb + target of a hunk header line.
330fn 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        // `DEL` takes no body and no trailing colon.
344        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
372// ── Section header scanning ──────────────────────────────────────────────
373
374struct HeaderScan {
375    path: String,
376    file_hash: Option<String>,
377}
378
379/// Parse a `[PATH]` or `[PATH#HASH]` section header line. Returns `None` for
380/// lines that are not bracketed headers, and for bracketed lines whose
381/// interior is malformed (embedded `#`, bad-length/non-hex tag, etc.).
382fn 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    // A trailing `#XXXX` (4 hex) is the snapshot tag, anchored at the body end
400    // so the path may legitimately contain whitespace.
401    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            // Slice is 4 ASCII hex chars — char-safe boundary.
415            file_hash = Some(line[(trailing_hash_start + 1)..body_end].to_uppercase());
416        }
417    }
418
419    // `#` is the path/tag separator and is not allowed inside the path body.
420    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// ── Token type ───────────────────────────────────────────────────────────
433
434/// One classified line of patch text.
435#[derive(Debug, Clone)]
436pub enum Token {
437    /// An empty line.
438    Blank {
439        /// 1-indexed line number in the source patch text.
440        line_num: u32,
441    },
442    /// `*** Begin Patch` — envelope start, consumed.
443    EnvelopeBegin {
444        /// 1-indexed line number in the source patch text.
445        line_num: u32,
446    },
447    /// `*** End Patch` — envelope end, terminates parsing.
448    EnvelopeEnd {
449        /// 1-indexed line number in the source patch text.
450        line_num: u32,
451    },
452    /// `*** Abort` — truncation sentinel, terminates parsing.
453    Abort {
454        /// 1-indexed line number in the source patch text.
455        line_num: u32,
456    },
457    /// `[PATH]` or `[PATH#HASH]` section header.
458    Header {
459        /// 1-indexed line number in the source patch text.
460        line_num: u32,
461        /// File path inside the brackets.
462        path: String,
463        /// Content hash tag after `#`, if the header carried one.
464        file_hash: Option<String>,
465    },
466    /// A hunk header verb + target.
467    Op {
468        /// 1-indexed line number in the source patch text.
469        line_num: u32,
470        /// Parsed verb and target of the hunk header.
471        target: BlockTarget,
472    },
473    /// A `+TEXT` body row.
474    PayloadLiteral {
475        /// 1-indexed line number in the source patch text.
476        line_num: u32,
477        /// Literal body text following the leading `+`.
478        text: String,
479    },
480    /// Anything else (contamination check happens downstream).
481    Raw {
482        /// 1-indexed line number in the source patch text.
483        line_num: u32,
484        /// Unmodified text of the unrecognized line.
485        text: String,
486    },
487}
488
489impl Token {
490    /// 1-indexed line number in the source patch text.
491    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// ── Streaming Tokenizer ──────────────────────────────────────────────────
548
549/// Stateful, reusable line classifier. Buffer text with [`feed`], flush the
550/// remainder with [`end`], and reset with [`reset`] before reuse.
551///
552/// [`feed`]: Tokenizer::feed
553/// [`end`]: Tokenizer::end
554/// [`reset`]: Tokenizer::reset
555#[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    /// Construct a fresh tokenizer at line 1.
570    pub fn new() -> Self {
571        Self {
572            buffer: String::new(),
573            next_line_num: 1,
574            closed: false,
575        }
576    }
577
578    /// Feed a chunk and return all complete-line tokens. A partial trailing
579    /// line (no trailing `\n`) is buffered until the next [`Self::feed`] or [`Self::end`].
580    /// No-op once [`Self::end`] has been called; call [`Self::reset`] to reuse.
581    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    /// Flush any buffered partial line as a final token.
590    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    /// Return to a fresh state for reuse.
610    pub fn reset(&mut self) {
611        self.buffer.clear();
612        self.next_line_num = 1;
613        self.closed = false;
614    }
615
616    /// Tokenize an entire input in one shot.
617    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    /// Classify a single line (no state).
625    pub fn tokenize(&self, line: &str, line_num: u32) -> Token {
626        classify_line(line, line_num)
627    }
628
629    /// True when `line` parses as a hunk header verb.
630    pub fn is_op(&self, line: &str) -> bool {
631        try_parse_hunk_header(line).is_some()
632    }
633
634    /// True when `line` parses as a `[PATH(#HASH)?]` header.
635    pub fn is_header(&self, line: &str) -> bool {
636        try_parse_header(line).is_some()
637    }
638
639    /// True when `line` is an envelope begin/end/abort marker.
640    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        // Drop consumed prefix; keep the remainder buffered.
667        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        // trailing partial line kept
684        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()); // non-hex
711        assert!(try_parse_header("[a#1A2]").is_none()); // too short
712        assert!(try_parse_header("[a#1A2B5]").is_none()); // too long
713    }
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        // line numbers ascend from 1
813        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}