Skip to main content

rumdl_lib/utils/
line_ending.rs

1use std::borrow::Cow;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum LineEnding {
5    Lf,
6    Crlf,
7    Mixed,
8}
9
10/// Maps byte offsets in LF-normalized content back to the original input.
11///
12/// rumdl normalizes CRLF to LF while linting. Machine-readable fixes, however,
13/// must address the bytes supplied by the caller, including mixed-line-ending
14/// input. Each entry records the normalized offset of a newline whose preceding
15/// carriage return was removed.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct NormalizedLineEndingMap {
18    crlf_newline_offsets: Vec<usize>,
19}
20
21impl NormalizedLineEndingMap {
22    pub fn new(original: &str) -> Self {
23        let bytes = original.as_bytes();
24        let mut crlf_newline_offsets = Vec::new();
25        let mut original_offset = 0;
26        let mut normalized_offset = 0;
27
28        while original_offset < bytes.len() {
29            if bytes[original_offset] == b'\r'
30                && original_offset + 1 < bytes.len()
31                && bytes[original_offset + 1] == b'\n'
32            {
33                crlf_newline_offsets.push(normalized_offset);
34                original_offset += 2;
35                normalized_offset += 1;
36            } else {
37                original_offset += 1;
38                normalized_offset += 1;
39            }
40        }
41
42        Self { crlf_newline_offsets }
43    }
44
45    /// Convert a byte boundary in normalized content to the corresponding byte
46    /// boundary in the original input.
47    pub fn original_offset(&self, normalized_offset: usize) -> usize {
48        normalized_offset
49            + self
50                .crlf_newline_offsets
51                .partition_point(|newline_offset| *newline_offset < normalized_offset)
52    }
53}
54
55pub fn detect_line_ending_enum(content: &str) -> LineEnding {
56    let bytes = content.as_bytes();
57    let mut has_crlf = false;
58    let mut has_standalone_lf = false;
59    let mut i = 0;
60
61    while i < bytes.len() {
62        if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
63            has_crlf = true;
64            i += 2;
65        } else if bytes[i] == b'\n' {
66            has_standalone_lf = true;
67            i += 1;
68        } else {
69            i += 1;
70        }
71        // Early exit once both types are found
72        if has_crlf && has_standalone_lf {
73            return LineEnding::Mixed;
74        }
75    }
76
77    match (has_crlf, has_standalone_lf) {
78        (true, true) => LineEnding::Mixed,
79        (true, false) => LineEnding::Crlf,
80        (false, _) => LineEnding::Lf,
81    }
82}
83
84pub fn detect_line_ending(content: &str) -> &'static str {
85    // Compatibility function matching the old signature
86    let crlf_count = content.matches("\r\n").count();
87    let lf_count = content.matches('\n').count() - crlf_count;
88
89    if crlf_count > lf_count { "\r\n" } else { "\n" }
90}
91
92pub fn normalize_line_ending<'a>(content: &'a str, target: LineEnding) -> Cow<'a, str> {
93    match target {
94        LineEnding::Lf => {
95            if !content.contains('\r') {
96                Cow::Borrowed(content)
97            } else {
98                Cow::Owned(content.replace("\r\n", "\n"))
99            }
100        }
101        LineEnding::Crlf => {
102            // First normalize everything to LF, then convert to CRLF
103            let normalized = content.replace("\r\n", "\n");
104            Cow::Owned(normalized.replace('\n', "\r\n"))
105        }
106        LineEnding::Mixed => Cow::Borrowed(content),
107    }
108}
109
110pub fn ensure_consistent_line_endings(original: &str, modified: &str) -> String {
111    let original_ending = detect_line_ending_enum(original);
112
113    // For mixed line endings, normalize to the most common one (like detect_line_ending does)
114    let target_ending = if original_ending == LineEnding::Mixed {
115        // Use the same logic as detect_line_ending: prefer the more common one
116        let crlf_count = original.matches("\r\n").count();
117        let lf_count = original.matches('\n').count() - crlf_count;
118        if crlf_count > lf_count {
119            LineEnding::Crlf
120        } else {
121            LineEnding::Lf
122        }
123    } else {
124        original_ending
125    };
126
127    let modified_ending = detect_line_ending_enum(modified);
128
129    if target_ending != modified_ending {
130        normalize_line_ending(modified, target_ending).into_owned()
131    } else {
132        modified.to_string()
133    }
134}
135
136pub fn get_line_ending_str(ending: LineEnding) -> &'static str {
137    match ending {
138        LineEnding::Lf => "\n",
139        LineEnding::Crlf => "\r\n",
140        LineEnding::Mixed => "\n", // Default to LF for mixed
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_detect_line_ending_enum() {
150        assert_eq!(detect_line_ending_enum("hello\nworld"), LineEnding::Lf);
151        assert_eq!(detect_line_ending_enum("hello\r\nworld"), LineEnding::Crlf);
152        assert_eq!(detect_line_ending_enum("hello\r\nworld\nmixed"), LineEnding::Mixed);
153        assert_eq!(detect_line_ending_enum("no line endings"), LineEnding::Lf);
154    }
155
156    #[test]
157    fn normalized_line_ending_map_handles_mixed_input() {
158        let original = "a\r\nb\nc\r\n";
159        let map = NormalizedLineEndingMap::new(original);
160
161        // Boundaries surrounding the two CRLF sequences gain one byte each,
162        // while the standalone LF does not.
163        assert_eq!(map.original_offset(1), 1);
164        assert_eq!(map.original_offset(2), 3);
165        assert_eq!(map.original_offset(4), 5);
166        assert_eq!(map.original_offset(6), 8);
167    }
168
169    #[test]
170    fn test_detect_line_ending() {
171        assert_eq!(detect_line_ending("hello\nworld"), "\n");
172        assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
173        assert_eq!(detect_line_ending("hello\r\nworld\nmixed"), "\n"); // More LF than CRLF
174        assert_eq!(detect_line_ending("no line endings"), "\n");
175    }
176
177    #[test]
178    fn test_normalize_line_ending() {
179        assert_eq!(normalize_line_ending("hello\r\nworld", LineEnding::Lf), "hello\nworld");
180        assert_eq!(
181            normalize_line_ending("hello\nworld", LineEnding::Crlf),
182            "hello\r\nworld"
183        );
184        assert_eq!(
185            normalize_line_ending("hello\r\nworld\nmixed", LineEnding::Lf),
186            "hello\nworld\nmixed"
187        );
188    }
189
190    #[test]
191    fn test_ensure_consistent_line_endings() {
192        let original = "hello\r\nworld";
193        let modified = "hello\nworld\nextra";
194        assert_eq!(
195            ensure_consistent_line_endings(original, modified),
196            "hello\r\nworld\r\nextra"
197        );
198
199        let original = "hello\nworld";
200        let modified = "hello\r\nworld\r\nextra";
201        assert_eq!(
202            ensure_consistent_line_endings(original, modified),
203            "hello\nworld\nextra"
204        );
205    }
206}