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    /// The original input, rebuilt from the LF-normalized content this map was
55    /// built from by putting back each carriage return normalization removed.
56    /// Exact for any input, mixed line endings included, where re-normalizing to
57    /// the detected line ending is not.
58    pub fn restore(&self, normalized: &str) -> String {
59        let mut restored = String::with_capacity(normalized.len() + self.crlf_newline_offsets.len());
60        let mut copied = 0;
61        for &newline_offset in &self.crlf_newline_offsets {
62            restored.push_str(&normalized[copied..newline_offset]);
63            restored.push('\r');
64            copied = newline_offset;
65        }
66        restored.push_str(&normalized[copied..]);
67        restored
68    }
69}
70
71pub fn detect_line_ending_enum(content: &str) -> LineEnding {
72    let bytes = content.as_bytes();
73    let mut has_crlf = false;
74    let mut has_standalone_lf = false;
75    let mut i = 0;
76
77    while i < bytes.len() {
78        if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
79            has_crlf = true;
80            i += 2;
81        } else if bytes[i] == b'\n' {
82            has_standalone_lf = true;
83            i += 1;
84        } else {
85            i += 1;
86        }
87        // Early exit once both types are found
88        if has_crlf && has_standalone_lf {
89            return LineEnding::Mixed;
90        }
91    }
92
93    match (has_crlf, has_standalone_lf) {
94        (true, true) => LineEnding::Mixed,
95        (true, false) => LineEnding::Crlf,
96        (false, _) => LineEnding::Lf,
97    }
98}
99
100pub fn detect_line_ending(content: &str) -> &'static str {
101    // Compatibility function matching the old signature
102    let crlf_count = content.matches("\r\n").count();
103    let lf_count = content.matches('\n').count() - crlf_count;
104
105    if crlf_count > lf_count { "\r\n" } else { "\n" }
106}
107
108pub fn normalize_line_ending<'a>(content: &'a str, target: LineEnding) -> Cow<'a, str> {
109    match target {
110        LineEnding::Lf => {
111            if !content.contains('\r') {
112                Cow::Borrowed(content)
113            } else {
114                Cow::Owned(content.replace("\r\n", "\n"))
115            }
116        }
117        LineEnding::Crlf => {
118            // First normalize everything to LF, then convert to CRLF
119            let normalized = content.replace("\r\n", "\n");
120            Cow::Owned(normalized.replace('\n', "\r\n"))
121        }
122        LineEnding::Mixed => Cow::Borrowed(content),
123    }
124}
125
126pub fn ensure_consistent_line_endings(original: &str, modified: &str) -> String {
127    let original_ending = detect_line_ending_enum(original);
128
129    // For mixed line endings, normalize to the most common one (like detect_line_ending does)
130    let target_ending = if original_ending == LineEnding::Mixed {
131        // Use the same logic as detect_line_ending: prefer the more common one
132        let crlf_count = original.matches("\r\n").count();
133        let lf_count = original.matches('\n').count() - crlf_count;
134        if crlf_count > lf_count {
135            LineEnding::Crlf
136        } else {
137            LineEnding::Lf
138        }
139    } else {
140        original_ending
141    };
142
143    let modified_ending = detect_line_ending_enum(modified);
144
145    if target_ending != modified_ending {
146        normalize_line_ending(modified, target_ending).into_owned()
147    } else {
148        modified.to_string()
149    }
150}
151
152pub fn get_line_ending_str(ending: LineEnding) -> &'static str {
153    match ending {
154        LineEnding::Lf => "\n",
155        LineEnding::Crlf => "\r\n",
156        LineEnding::Mixed => "\n", // Default to LF for mixed
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_detect_line_ending_enum() {
166        assert_eq!(detect_line_ending_enum("hello\nworld"), LineEnding::Lf);
167        assert_eq!(detect_line_ending_enum("hello\r\nworld"), LineEnding::Crlf);
168        assert_eq!(detect_line_ending_enum("hello\r\nworld\nmixed"), LineEnding::Mixed);
169        assert_eq!(detect_line_ending_enum("no line endings"), LineEnding::Lf);
170    }
171
172    #[test]
173    fn normalized_line_ending_map_handles_mixed_input() {
174        let original = "a\r\nb\nc\r\n";
175        let map = NormalizedLineEndingMap::new(original);
176
177        // Boundaries surrounding the two CRLF sequences gain one byte each,
178        // while the standalone LF does not.
179        assert_eq!(map.original_offset(1), 1);
180        assert_eq!(map.original_offset(2), 3);
181        assert_eq!(map.original_offset(4), 5);
182        assert_eq!(map.original_offset(6), 8);
183    }
184
185    #[test]
186    fn normalized_line_ending_map_restores_the_original_bytes() {
187        for original in [
188            "",
189            "no newline",
190            "a\nb\n",
191            "a\r\nb\r\n",
192            "a\r\nb\nc\r\n",
193            "a\nb\r\nc",
194            "\r\n\r\n\n",
195            "lone\rcarriage\r\nreturn\r",
196            "é\r\n日本\n",
197        ] {
198            let normalized = normalize_line_ending(original, LineEnding::Lf);
199            let map = NormalizedLineEndingMap::new(original);
200            assert_eq!(map.restore(&normalized), original, "{original:?}");
201        }
202    }
203
204    #[test]
205    fn test_detect_line_ending() {
206        assert_eq!(detect_line_ending("hello\nworld"), "\n");
207        assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
208        assert_eq!(detect_line_ending("hello\r\nworld\nmixed"), "\n"); // More LF than CRLF
209        assert_eq!(detect_line_ending("no line endings"), "\n");
210    }
211
212    #[test]
213    fn test_normalize_line_ending() {
214        assert_eq!(normalize_line_ending("hello\r\nworld", LineEnding::Lf), "hello\nworld");
215        assert_eq!(
216            normalize_line_ending("hello\nworld", LineEnding::Crlf),
217            "hello\r\nworld"
218        );
219        assert_eq!(
220            normalize_line_ending("hello\r\nworld\nmixed", LineEnding::Lf),
221            "hello\nworld\nmixed"
222        );
223    }
224
225    #[test]
226    fn test_ensure_consistent_line_endings() {
227        let original = "hello\r\nworld";
228        let modified = "hello\nworld\nextra";
229        assert_eq!(
230            ensure_consistent_line_endings(original, modified),
231            "hello\r\nworld\r\nextra"
232        );
233
234        let original = "hello\nworld";
235        let modified = "hello\r\nworld\r\nextra";
236        assert_eq!(
237            ensure_consistent_line_endings(original, modified),
238            "hello\nworld\nextra"
239        );
240    }
241}