1#[cfg(doctest)]
2doc_comment::doctest!("../README.md");
3
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8#[allow(clippy::upper_case_acronyms)]
9pub enum LineEnding {
10 LF,
12 CRLF,
14 CR,
16}
17
18pub type LineEndingScores = HashMap<LineEnding, usize>;
27
28impl From<&str> for LineEnding {
29 fn from(s: &str) -> Self {
43 let scores = Self::score_mixed_types(s);
44
45 let crlf_score = *scores.get(&Self::CRLF).unwrap_or(&0);
46 let cr_score = *scores.get(&Self::CR).unwrap_or(&0);
47 let lf_score = *scores.get(&Self::LF).unwrap_or(&0);
48
49 let max_score = crlf_score.max(cr_score).max(lf_score);
51
52 if max_score == 0 || crlf_score == max_score {
53 Self::CRLF
56 } else if cr_score == max_score {
57 Self::CR
58 } else {
59 Self::LF
60 }
61 }
62}
63
64impl LineEnding {
65 pub fn score_mixed_types(s: &str) -> LineEndingScores {
89 let crlf_score = Self::CRLF.split_with(s).len().saturating_sub(1);
90
91 let cr_score = Self::CR.split_with(s).len().saturating_sub(1) - crlf_score;
93
94 let lf_score = Self::LF.split_with(s).len().saturating_sub(1) - crlf_score;
96
97 [
98 (LineEnding::CRLF, crlf_score),
99 (LineEnding::CR, cr_score),
100 (LineEnding::LF, lf_score),
101 ]
102 .into_iter()
103 .collect()
104 }
105
106 pub fn as_str(&self) -> &'static str {
118 match self {
119 Self::LF => "\n",
120 Self::CRLF => "\r\n",
121 Self::CR => "\r",
122 }
123 }
124
125 pub fn normalize(s: &str) -> String {
136 s.replace("\r\n", "\n").replace("\r", "\n")
137 }
138
139 pub fn denormalize(&self, s: &str) -> String {
151 s.replace("\n", self.as_str())
152 }
153
154 pub fn split(s: &str) -> Vec<String> {
167 let line_ending = Self::from(s).as_str();
168 s.split(line_ending).map(String::from).collect()
169 }
170
171 pub fn split_with(&self, s: &str) -> Vec<String> {
193 s.split(self.as_str()).map(String::from).collect()
194 }
195
196 pub fn join(&self, lines: Vec<String>) -> String {
208 lines.join(self.as_str())
209 }
210
211 pub fn apply(&self, s: &str) -> String {
223 let normalized = Self::normalize(s);
224 normalized.replace("\n", self.as_str())
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn get_readme_contents() -> String {
233 use std::fs::File;
234 use std::io::Read;
235
236 let readme_file = "README.md";
237
238 let mut read_content = String::new();
240 File::open(readme_file)
241 .unwrap_or_else(|_| panic!("Failed to open {}", readme_file))
242 .read_to_string(&mut read_content)
243 .unwrap_or_else(|_| panic!("Failed to read {}", readme_file));
244
245 read_content
246 }
247
248 #[test]
249 fn detects_platform_line_ending_correctly() {
250 let detected = LineEnding::from(get_readme_contents().as_str());
252
253 #[cfg(target_os = "windows")]
255 assert_eq!(detected, LineEnding::CRLF, "Windows should detect CRLF");
256
257 #[cfg(target_family = "unix")]
258 assert_eq!(detected, LineEnding::LF, "Unix/macOS should detect LF");
259 }
260
261 #[test]
262 fn detects_lf_correctly() {
263 let sample = "first line\nsecond line\nthird line";
264 assert_eq!(LineEnding::from(sample), LineEnding::LF);
265 }
266
267 #[test]
268 fn detects_crlf_correctly() {
269 let sample = "first line\r\nsecond line\r\nthird line";
270 assert_eq!(LineEnding::from(sample), LineEnding::CRLF);
271 }
272
273 #[test]
274 fn detects_cr_correctly() {
275 let sample = "first line\rsecond line\rthird line";
276 assert_eq!(LineEnding::from(sample), LineEnding::CR);
277 }
278
279 #[test]
280 fn normalize_converts_all_to_lf() {
281 let crlf = "first\r\nsecond\r\nthird";
282 let cr = "first\rsecond\rthird";
283 let lf = "first\nsecond\nthird";
284
285 assert_eq!(LineEnding::normalize(crlf), lf);
286 assert_eq!(LineEnding::normalize(cr), lf);
287 assert_eq!(LineEnding::normalize(lf), lf);
288 }
289
290 #[test]
291 fn splits_into_lines() {
292 let readme_contents = get_readme_contents();
293 let readme_lines = LineEnding::split(&readme_contents);
294
295 assert_eq!(readme_lines.first().unwrap(), "# Rust Line Endings");
296
297 let crlf_lines = LineEnding::split("first\r\nsecond\r\nthird");
298 let cr_lines = LineEnding::split("first\rsecond\rthird");
299 let lf_lines = LineEnding::split("first\nsecond\nthird");
300
301 let expected = vec!["first", "second", "third"];
302
303 assert_eq!(crlf_lines, expected);
304 assert_eq!(cr_lines, expected);
305 assert_eq!(lf_lines, expected);
306 }
307
308 #[test]
309 fn restore_correctly_applies_line_endings() {
310 let text = "first\nsecond\nthird";
311 let crlf_restored = LineEnding::CRLF.denormalize(text);
312 let cr_restored = LineEnding::CR.denormalize(text);
313 let lf_restored = LineEnding::LF.denormalize(text);
314
315 assert_eq!(crlf_restored, "first\r\nsecond\r\nthird");
316 assert_eq!(cr_restored, "first\rsecond\rthird");
317 assert_eq!(lf_restored, "first\nsecond\nthird");
318 }
319
320 #[test]
321 fn applies_correct_line_endings() {
322 let lines = vec![
323 "first".to_string(),
324 "second".to_string(),
325 "third".to_string(),
326 ];
327
328 assert_eq!(
329 LineEnding::CRLF.join(lines.clone()),
330 "first\r\nsecond\r\nthird"
331 );
332 assert_eq!(LineEnding::CR.join(lines.clone()), "first\rsecond\rthird");
333 assert_eq!(LineEnding::LF.join(lines.clone()), "first\nsecond\nthird");
334 }
335
336 #[test]
337 fn apply_correctly_applies_line_endings() {
338 let mixed_text = "first line\r\nsecond line\rthird line\nfourth line\n";
339
340 assert_eq!(
341 LineEnding::CRLF.apply(mixed_text),
342 "first line\r\nsecond line\r\nthird line\r\nfourth line\r\n"
343 );
344 assert_eq!(
345 LineEnding::CR.apply(mixed_text),
346 "first line\rsecond line\rthird line\rfourth line\r"
347 );
348 assert_eq!(
349 LineEnding::LF.apply(mixed_text),
350 "first line\nsecond line\nthird line\nfourth line\n"
351 );
352 }
353
354 #[test]
355 fn handles_mixed_line_endings() {
356 let mostly_lf = "line1\nline2\r\nline3\rline4\nline5\nline6\n";
358 assert_eq!(LineEnding::from(mostly_lf), LineEnding::LF);
359 assert_eq!(
360 LineEnding::score_mixed_types(mostly_lf,),
361 [
362 (LineEnding::CRLF, 1),
363 (LineEnding::CR, 1),
364 (LineEnding::LF, 4),
365 ]
366 .into_iter()
367 .collect::<LineEndingScores>()
368 );
369
370 let mostly_crlf = "line1\r\nline2\r\nline3\nline4\rline5\r\nline6\r\n";
372 assert_eq!(LineEnding::from(mostly_crlf), LineEnding::CRLF);
373 assert_eq!(
374 LineEnding::score_mixed_types(mostly_crlf,),
375 [
376 (LineEnding::CRLF, 4),
377 (LineEnding::CR, 1),
378 (LineEnding::LF, 1),
379 ]
380 .into_iter()
381 .collect::<LineEndingScores>()
382 );
383
384 let mostly_cr = "line1\rline2\r\nline3\rline4\nline5\rline6\r";
386 assert_eq!(LineEnding::from(mostly_cr), LineEnding::CR);
387 assert_eq!(
388 LineEnding::score_mixed_types(mostly_cr,),
389 [
390 (LineEnding::CRLF, 1),
391 (LineEnding::CR, 4),
392 (LineEnding::LF, 1),
393 ]
394 .into_iter()
395 .collect::<LineEndingScores>()
396 );
397 }
398
399 #[test]
400 fn handles_mixed_line_edge_cases() {
401 let mostly_crlf = "line1\r\nline2\r\nline3\nline4\r\nline5\r\n";
403 assert_eq!(LineEnding::from(mostly_crlf), LineEnding::CRLF); let equal_mixed = "line1\r\nline2\nline3\rline4\r\nline5\nline6\r";
407 assert_eq!(LineEnding::from(equal_mixed), LineEnding::CRLF); let mixed_on_one_line = "line1\r\nline2\rline3\r\nline4\r\nline5\r";
411 assert_eq!(LineEnding::from(mixed_on_one_line), LineEnding::CRLF); let empty_text = "";
415 assert_eq!(LineEnding::from(empty_text), LineEnding::CRLF); }
417
418 #[test]
419 fn ignores_escaped_line_endings_in_split() {
420 let input_lf = "First\\nSecond\\nThird";
421 let input_crlf = "First\\r\\nSecond\\r\\nThird";
422 let input_cr = "First\\rSecond\\rThird";
423
424 assert_eq!(LineEnding::split(input_lf), vec!["First\\nSecond\\nThird"]);
426 assert_eq!(
427 LineEnding::split(input_crlf),
428 vec!["First\\r\\nSecond\\r\\nThird"]
429 );
430 assert_eq!(LineEnding::split(input_cr), vec!["First\\rSecond\\rThird"]);
431 }
432
433 #[test]
434 fn split_does_not_split_on_escaped_line_endings() {
435 let input_lf = "First\\nSecond\\nThird";
436 let input_crlf = "First\\r\\nSecond\\r\\nThird";
437 let input_cr = "First\\rSecond\\rThird";
438
439 assert_eq!(LineEnding::split(input_lf), vec!["First\\nSecond\\nThird"]);
441 assert_eq!(
442 LineEnding::split(input_crlf),
443 vec!["First\\r\\nSecond\\r\\nThird"]
444 );
445 assert_eq!(LineEnding::split(input_cr), vec!["First\\rSecond\\rThird"]);
446 }
447
448 #[test]
449 fn split_correctly_splits_on_actual_line_endings() {
450 let input_lf = "First\nSecond\nThird";
451 let input_crlf = "First\r\nSecond\r\nThird";
452 let input_cr = "First\rSecond\rThird";
453
454 assert_eq!(
456 LineEnding::split(input_lf),
457 vec!["First", "Second", "Third"]
458 );
459 assert_eq!(
460 LineEnding::split(input_crlf),
461 vec!["First", "Second", "Third"]
462 );
463 assert_eq!(
464 LineEnding::split(input_cr),
465 vec!["First", "Second", "Third"]
466 );
467 }
468
469 #[test]
470 fn split_detects_mixed_escaped_and_actual_line_endings() {
471 let input_lf = "First\\nSecond\nThird";
473 assert_eq!(LineEnding::split(input_lf), vec!["First\\nSecond", "Third"]);
474
475 let input_crlf = "First\\r\\nSecond\r\nThird";
477 assert_eq!(
478 LineEnding::split(input_crlf),
479 vec!["First\\r\\nSecond", "Third"]
480 );
481
482 let input_cr = "First\\rSecond\rThird";
484 assert_eq!(LineEnding::split(input_cr), vec!["First\\rSecond", "Third"]);
485 }
486}