rumdl_lib/utils/
line_ending.rs1use std::borrow::Cow;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum LineEnding {
5 Lf,
6 Crlf,
7 Mixed,
8}
9
10#[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 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 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 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 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 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 let target_ending = if original_ending == LineEnding::Mixed {
131 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", }
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 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"); 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}