Skip to main content

oxicode_hashline/
format.rs

1//! Hashline format primitives: sigils, separators, and display helpers.
2//! Single source of truth for the parser, tokenizer, prompt, and grammar.
3//!
4//! Ported from omp `packages/hashline/src/format.ts`.
5
6use crate::types::Cursor;
7
8// ── Sigils & separators ──────────────────────────────────────────────────
9
10/// Opening sigil of a hashline section header.
11pub const HL_FILE_PREFIX: &str = "[";
12/// Closing sigil of a hashline section header.
13pub const HL_FILE_SUFFIX: &str = "]";
14/// Sigil prefixing each literal payload row.
15pub const HL_PAYLOAD_REPLACE: char = '+';
16
17/// Keyword marking a replacement hunk header.
18pub const HL_REPLACE_KEYWORD: &str = "SWAP";
19/// Keyword marking a deletion hunk header.
20pub const HL_DELETE_KEYWORD: &str = "DEL";
21/// Keyword marking an insertion hunk header.
22pub const HL_INSERT_KEYWORD: &str = "INS";
23/// `INS` sub-keyword: insert before the anchor line.
24pub const HL_INSERT_BEFORE: &str = "PRE";
25/// `INS` sub-keyword: insert after the anchor line.
26pub const HL_INSERT_AFTER: &str = "POST";
27/// `INS` sub-keyword: insert at the very top of the file.
28pub const HL_INSERT_HEAD: &str = "HEAD";
29/// `INS` sub-keyword: insert at the very bottom of the file.
30pub const HL_INSERT_TAIL: &str = "TAIL";
31
32/// Colon terminating a hunk header.
33pub const HL_HEADER_COLON: char = ':';
34/// Separator between a path and its hash tag.
35pub const HL_FILE_HASH_SEP: char = '#';
36/// Separator in an inclusive `start.=end` range.
37pub const HL_RANGE_SEP: &str = ".=";
38/// Separator between a line number and its body text.
39pub const HL_LINE_BODY_SEP: char = ':';
40
41/// Length, in hex characters, of a content hash tag.
42pub const HL_FILE_HASH_LENGTH: usize = 4;
43
44// ── Normalisation & hashing ──────────────────────────────────────────────
45
46/// Trim trailing `[ \t\r]` from every line (and the final line) in a single
47/// pass so CRLF endings and display-trimmed lines do not invalidate a tag.
48///
49/// Equivalent to omp's `text.replace(/[ \t\r]+(?=\n|$)/g, "")`.
50fn normalize_file_hash_text(text: &str) -> String {
51    // Manual scan to avoid a regex dependency.
52    let bytes = text.as_bytes();
53    let mut out = Vec::with_capacity(bytes.len());
54    let mut i = 0;
55    let len = bytes.len();
56    while i < len {
57        // Check if we're at a run of [ \t\r] followed by \n or end-of-string.
58        if matches!(bytes[i], b' ' | b'\t' | b'\r') {
59            // Find the end of the whitespace run.
60            let start = i;
61            while i < len && matches!(bytes[i], b' ' | b'\t' | b'\r') {
62                i += 1;
63            }
64            // If the next char is \n or end-of-string, drop the run.
65            if i >= len || bytes[i] == b'\n' {
66                // skip — don't copy the whitespace
67            } else {
68                out.extend_from_slice(&bytes[start..i]);
69            }
70        } else {
71            out.push(bytes[i]);
72            i += 1;
73        }
74    }
75    // Safety: input is valid UTF-8, and we only remove ASCII whitespace bytes.
76    String::from_utf8(out).expect("normalization preserves UTF-8 validity")
77}
78
79/// Compute the content-derived hash tag carried by a hashline section header.
80///
81/// xxHash32 seed 0, low 16 bits, 4-hex uppercase.
82/// Must be byte-identical to omp's `computeFileHash`.
83pub fn compute_file_hash(text: &str) -> String {
84    let normalized = normalize_file_hash_text(text);
85    let low16 = xxhash_rust::xxh32::xxh32(normalized.as_bytes(), 0) & 0xFFFF;
86    format!("{:04X}", low16)
87}
88
89// ── Display helpers ──────────────────────────────────────────────────────
90
91/// Format a concrete replacement hunk header: `SWAP start.=end:`.
92pub fn format_replace_header(start: u32, end: u32) -> String {
93    format!("{HL_REPLACE_KEYWORD} {start}{HL_RANGE_SEP}{end}{HL_HEADER_COLON}")
94}
95
96/// Format a concrete deletion hunk header: `DEL start` or `DEL start.=end`.
97pub fn format_delete_header(start: u32, end: u32) -> String {
98    if start == end {
99        format!("{HL_DELETE_KEYWORD} {start}")
100    } else {
101        format!("{HL_DELETE_KEYWORD} {start}{HL_RANGE_SEP}{end}")
102    }
103}
104
105/// Format an insertion hunk header for a cursor position.
106pub fn format_insert_header(cursor: &Cursor) -> String {
107    match cursor {
108        Cursor::BeforeAnchor(anchor) => {
109            format!(
110                "{HL_INSERT_KEYWORD}.{HL_INSERT_BEFORE} {}{HL_HEADER_COLON}",
111                anchor.line
112            )
113        }
114        Cursor::AfterAnchor(anchor) => {
115            format!(
116                "{HL_INSERT_KEYWORD}.{HL_INSERT_AFTER} {}{HL_HEADER_COLON}",
117                anchor.line
118            )
119        }
120        Cursor::Bof => {
121            format!("{HL_INSERT_KEYWORD}.{HL_INSERT_HEAD}{HL_HEADER_COLON}")
122        }
123        Cursor::Eof => {
124            format!("{HL_INSERT_KEYWORD}.{HL_INSERT_TAIL}{HL_HEADER_COLON}")
125        }
126    }
127}
128
129/// Format a hashline section header: `[path#HASH]`.
130pub fn format_hashline_header(file_path: &str, file_hash: &str) -> String {
131    format!("{HL_FILE_PREFIX}{file_path}{HL_FILE_HASH_SEP}{file_hash}{HL_FILE_SUFFIX}")
132}
133
134/// Format a single numbered line: `LINE:TEXT`.
135pub fn format_numbered_line(line_number: u32, line: &str) -> String {
136    format!("{line_number}{HL_LINE_BODY_SEP}{line}")
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::types::Anchor;
143
144    #[test]
145    fn hash_stable_and_uppercase() {
146        let tag = compute_file_hash("hello world\n");
147        assert_eq!(tag.len(), 4);
148        assert!(
149            tag.chars()
150                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
151        );
152    }
153
154    #[test]
155    fn hash_trailing_whitespace_invariant() {
156        let a = compute_file_hash("line one\nline two\n");
157        let b = compute_file_hash("line one   \nline two\t\r\n");
158        assert_eq!(a, b, "trailing whitespace must not change hash");
159    }
160
161    #[test]
162    fn hash_crlf_invariant() {
163        let a = compute_file_hash("alpha\nbeta\n");
164        let b = compute_file_hash("alpha\r\nbeta\r\n");
165        assert_eq!(a, b, "CRLF must not change hash");
166    }
167
168    #[test]
169    fn hash_empty_string() {
170        let tag = compute_file_hash("");
171        assert_eq!(tag.len(), 4);
172    }
173
174    #[test]
175    fn format_headers() {
176        assert_eq!(format_replace_header(5, 10), "SWAP 5.=10:");
177        assert_eq!(format_delete_header(7, 7), "DEL 7");
178        assert_eq!(format_delete_header(7, 12), "DEL 7.=12");
179        assert_eq!(
180            format_insert_header(&Cursor::BeforeAnchor(Anchor { line: 3 })),
181            "INS.PRE 3:"
182        );
183        assert_eq!(
184            format_hashline_header("src/foo.rs", "1A2B"),
185            "[src/foo.rs#1A2B]"
186        );
187        assert_eq!(format_numbered_line(42, "let x = 1;"), "42:let x = 1;");
188    }
189}