Skip to main content

oxicode_hashline/
normalize.rs

1//! Minimal text-shape normalization: line-ending detection / round-trip and
2//! BOM stripping. The patcher uses these to canonicalize text to LF before
3//! applying edits and to restore the original shape on write-back.
4//!
5//! Ported from omp `packages/hashline/src/normalize.ts`.
6
7/// Line ending style.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum LineEnding {
10    /// Windows-style carriage-return + line-feed (`\r\n`).
11    Crlf,
12    /// Unix-style line-feed (`\n`).
13    Lf,
14}
15
16/// Detect the first line ending style in `content`. Defaults to LF when
17/// neither is present.
18pub fn detect_line_ending(content: &str) -> LineEnding {
19    let crlf_idx = content.find("\r\n");
20    let lf_idx = content.find('\n');
21    match (crlf_idx, lf_idx) {
22        (None, None) | (_, None) => LineEnding::Lf,
23        (None, Some(_)) => LineEnding::Lf,
24        (Some(c), Some(l)) => {
25            if c < l {
26                LineEnding::Crlf
27            } else {
28                LineEnding::Lf
29            }
30        }
31    }
32}
33
34/// Normalize every line ending to LF.
35pub fn normalize_to_lf(text: &str) -> String {
36    text.replace("\r\n", "\n").replace('\r', "\n")
37}
38
39/// Re-encode LF text with the requested line ending.
40pub fn restore_line_endings(text: &str, ending: LineEnding) -> String {
41    match ending {
42        LineEnding::Crlf => text.replace('\n', "\r\n"),
43        LineEnding::Lf => text.to_string(),
44    }
45}
46
47/// BOM strip result.
48pub struct BomResult<'a> {
49    /// Either empty or the BOM sequence.
50    pub bom: &'a str,
51    /// Text with any leading BOM removed.
52    pub text: &'a str,
53}
54
55/// Strip a UTF-8 BOM if present.
56pub fn strip_bom(content: &str) -> BomResult<'_> {
57    if let Some(rest) = content.strip_prefix('\u{feff}') {
58        BomResult {
59            bom: "\u{feff}",
60            text: rest,
61        }
62    } else {
63        BomResult {
64            bom: "",
65            text: content,
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn detect_endings() {
76        assert_eq!(detect_line_ending("a\r\nb"), LineEnding::Crlf);
77        assert_eq!(detect_line_ending("a\nb"), LineEnding::Lf);
78        assert_eq!(detect_line_ending("no newlines"), LineEnding::Lf);
79        assert_eq!(detect_line_ending("a\nb\r\nc"), LineEnding::Lf);
80    }
81
82    #[test]
83    fn normalize_crlf() {
84        assert_eq!(normalize_to_lf("a\r\nb\r\nc"), "a\nb\nc");
85        assert_eq!(normalize_to_lf("a\rb"), "a\nb");
86    }
87
88    #[test]
89    fn restore_crlf() {
90        assert_eq!(restore_line_endings("a\nb", LineEnding::Crlf), "a\r\nb");
91        assert_eq!(restore_line_endings("a\nb", LineEnding::Lf), "a\nb");
92    }
93
94    #[test]
95    fn strip_utf8_bom() {
96        let r = strip_bom("\u{feff}hello");
97        assert_eq!(r.bom, "\u{feff}");
98        assert_eq!(r.text, "hello");
99
100        let r = strip_bom("hello");
101        assert_eq!(r.bom, "");
102        assert_eq!(r.text, "hello");
103    }
104}