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    /// Normalized offsets of the newlines that were a bare LF, recorded only
20    /// when the original mixes line endings: text written into such a file
21    /// takes the ending of the line it follows.
22    mixed_lf_newline_offsets: Vec<usize>,
23    /// Whether every line of the original ends in CRLF, so text written into it
24    /// takes CRLF as well.
25    all_crlf: bool,
26}
27
28impl NormalizedLineEndingMap {
29    pub fn new(original: &str) -> Self {
30        // Ordinary LF input needs neither a map nor a vector of LF offsets.
31        if !original.contains('\r') {
32            return Self::default();
33        }
34        let bytes = original.as_bytes();
35        let mut crlf_newline_offsets = Vec::new();
36        let mut lf_newline_offsets = Vec::new();
37        for (offset, _) in original.match_indices('\n') {
38            let normalized_offset = offset - crlf_newline_offsets.len();
39            if offset > 0 && bytes[offset - 1] == b'\r' {
40                crlf_newline_offsets.push(normalized_offset - 1);
41            } else {
42                lf_newline_offsets.push(normalized_offset);
43            }
44        }
45
46        let all_crlf = !crlf_newline_offsets.is_empty() && lf_newline_offsets.is_empty();
47        if crlf_newline_offsets.is_empty() {
48            lf_newline_offsets.clear();
49        }
50        Self {
51            crlf_newline_offsets,
52            mixed_lf_newline_offsets: lf_newline_offsets,
53            all_crlf,
54        }
55    }
56
57    fn is_mixed(&self) -> bool {
58        !self.mixed_lf_newline_offsets.is_empty()
59    }
60
61    /// Text to insert into the original input at `normalized_offset`:
62    /// LF-normalized `text` with the line ending `fmt` would give it there.
63    /// That is the file's own ending, or in a file with mixed endings the
64    /// ending of the line the insertion follows.
65    pub fn original_text_at<'a>(&self, text: &'a str, normalized_offset: usize) -> Cow<'a, str> {
66        if !text.contains('\n') {
67            return Cow::Borrowed(text);
68        }
69        if self.all_crlf {
70            return normalize_line_ending(text, LineEnding::Crlf);
71        }
72        if self.is_mixed() && self.ending_before(normalized_offset) == "\r\n" {
73            return normalize_line_ending(text, LineEnding::Crlf);
74        }
75        Cow::Borrowed(text)
76    }
77
78    /// The ending of the line holding the byte before `normalized_offset`, or
79    /// of the first line at the start of the text. An unterminated last line
80    /// has none of its own and gives the ending of the line before it.
81    fn ending_before(&self, normalized_offset: usize) -> &'static str {
82        let position = normalized_offset.saturating_sub(1);
83        let next = |offsets: &[usize]| {
84            offsets
85                .get(offsets.partition_point(|&offset| offset < position))
86                .copied()
87        };
88        match (next(&self.crlf_newline_offsets), next(&self.mixed_lf_newline_offsets)) {
89            (Some(crlf), Some(lf)) => {
90                if crlf < lf {
91                    "\r\n"
92                } else {
93                    "\n"
94                }
95            }
96            (Some(_), None) => "\r\n",
97            (None, Some(_)) => "\n",
98            (None, None) => {
99                if self.crlf_newline_offsets.last() > self.mixed_lf_newline_offsets.last() {
100                    "\r\n"
101                } else {
102                    "\n"
103                }
104            }
105        }
106    }
107
108    /// Write `fixed`, the LF-normalized content after fixes, back in the line
109    /// endings of the original this map was built from. `normalized_original`
110    /// is the LF-normalized original the fixes started from.
111    ///
112    /// A file with one line ending gets it throughout. In a file with mixed
113    /// endings, a diff of the two texts decides each line ending: a newline the
114    /// fixes kept keeps its original ending, and a newline they inserted takes
115    /// the ending of the line it was inserted into, the same ending
116    /// [`Self::original_text_at`] gives a JSON fix inserting it there. A line
117    /// diff finds the changed regions and a byte diff aligns the newlines
118    /// inside each one, so the cost follows the size of the edits.
119    pub fn restore_fixed<'a>(&self, normalized_original: &str, fixed: &'a str) -> Cow<'a, str> {
120        if self.all_crlf {
121            return normalize_line_ending(fixed, LineEnding::Crlf);
122        }
123        if !self.is_mixed() {
124            return Cow::Borrowed(fixed);
125        }
126
127        let old: Vec<&str> = normalized_original.split_inclusive('\n').collect();
128        let new: Vec<&str> = fixed.split_inclusive('\n').collect();
129        let line_starts = |lines: &[&str]| {
130            let mut starts = Vec::with_capacity(lines.len() + 1);
131            starts.push(0);
132            for line in lines {
133                starts.push(starts.last().unwrap() + line.len());
134            }
135            starts
136        };
137        let old_starts = line_starts(&old);
138        let new_starts = line_starts(&new);
139
140        // The ending of each newline in `fixed`, in order.
141        let mut endings: Vec<&'static str> = Vec::with_capacity(new.len());
142        // A newline kept from the original at `offset`, or inserted at `offset`.
143        let kept = |offset: usize| self.ending_before(offset + 1);
144        let inserted = |offset: usize| self.ending_before(offset);
145        let newlines = |text: &str| text.bytes().filter(|&b| b == b'\n').count();
146
147        for op in similar::capture_diff_slices(similar::Algorithm::Myers, &old, &new) {
148            match op {
149                similar::DiffOp::Equal { old_index, len, .. } => {
150                    for line in old_index..old_index + len {
151                        if old[line].ends_with('\n') {
152                            endings.push(kept(old_starts[line + 1] - 1));
153                        }
154                    }
155                }
156                similar::DiffOp::Delete { .. } => {}
157                similar::DiffOp::Insert {
158                    old_index,
159                    new_index,
160                    new_len,
161                } => {
162                    let at = inserted(old_starts[old_index]);
163                    let count = newlines(&fixed[new_starts[new_index]..new_starts[new_index + new_len]]);
164                    endings.extend(std::iter::repeat_n(at, count));
165                }
166                similar::DiffOp::Replace {
167                    old_index,
168                    old_len,
169                    new_index,
170                    new_len,
171                } => {
172                    let base = old_starts[old_index];
173                    let old_bytes = &normalized_original.as_bytes()[base..old_starts[old_index + old_len]];
174                    let new_bytes = &fixed.as_bytes()[new_starts[new_index]..new_starts[new_index + new_len]];
175                    for byte_op in similar::capture_diff_slices(similar::Algorithm::Myers, old_bytes, new_bytes) {
176                        let (old_at, new_range, is_kept) = match byte_op {
177                            similar::DiffOp::Equal {
178                                old_index,
179                                new_index,
180                                len,
181                            } => (old_index, new_index..new_index + len, true),
182                            similar::DiffOp::Delete { .. } => continue,
183                            similar::DiffOp::Insert {
184                                old_index,
185                                new_index,
186                                new_len,
187                            }
188                            | similar::DiffOp::Replace {
189                                old_index,
190                                new_index,
191                                new_len,
192                                ..
193                            } => (old_index, new_index..new_index + new_len, false),
194                        };
195                        for (k, &byte) in new_bytes[new_range].iter().enumerate() {
196                            if byte == b'\n' {
197                                endings.push(if is_kept {
198                                    kept(base + old_at + k)
199                                } else {
200                                    inserted(base + old_at)
201                                });
202                            }
203                        }
204                    }
205                }
206            }
207        }
208
209        let mut restored = String::with_capacity(fixed.len() + endings.len());
210        let mut endings = endings.into_iter();
211        for line in &new {
212            match line.strip_suffix('\n') {
213                Some(body) => {
214                    restored.push_str(body);
215                    restored.push_str(endings.next().unwrap_or("\n"));
216                }
217                None => restored.push_str(line),
218            }
219        }
220        Cow::Owned(restored)
221    }
222
223    /// Convert a byte boundary in normalized content to the corresponding byte
224    /// boundary in the original input.
225    pub fn original_offset(&self, normalized_offset: usize) -> usize {
226        normalized_offset
227            + self
228                .crlf_newline_offsets
229                .partition_point(|newline_offset| *newline_offset < normalized_offset)
230    }
231
232    /// The original input, rebuilt from the LF-normalized content this map was
233    /// built from by putting back each carriage return normalization removed.
234    /// Exact for any input, mixed line endings included, where re-normalizing to
235    /// the detected line ending is not.
236    pub fn restore(&self, normalized: &str) -> String {
237        let mut restored = String::with_capacity(normalized.len() + self.crlf_newline_offsets.len());
238        let mut copied = 0;
239        for &newline_offset in &self.crlf_newline_offsets {
240            restored.push_str(&normalized[copied..newline_offset]);
241            restored.push('\r');
242            copied = newline_offset;
243        }
244        restored.push_str(&normalized[copied..]);
245        restored
246    }
247}
248
249/// The line ending text inserted at byte `offset` of `content` takes, by the
250/// rule `NormalizedLineEndingMap::original_text_at` applies to normalized
251/// offsets: the ending of the line holding the byte before `offset` (the first
252/// line at offset 0), or for an unterminated last line the ending before it.
253/// `\n` when `content` has no line ending at all.
254pub fn line_ending_before(content: &str, offset: usize) -> &'static str {
255    let bytes = content.as_bytes();
256    let position = offset.saturating_sub(1).min(bytes.len());
257    let newline = bytes[position..]
258        .iter()
259        .position(|&b| b == b'\n')
260        .map(|found| position + found)
261        .or_else(|| bytes.iter().rposition(|&b| b == b'\n'));
262    match newline {
263        Some(newline) if newline > 0 && bytes[newline - 1] == b'\r' => "\r\n",
264        _ => "\n",
265    }
266}
267
268pub fn detect_line_ending_enum(content: &str) -> LineEnding {
269    let bytes = content.as_bytes();
270    // LF is the common case. Search for CR before examining each newline.
271    if memchr::memchr(b'\r', bytes).is_none() {
272        return LineEnding::Lf;
273    }
274    let mut crlf = false;
275    let mut lf = false;
276    for newline in memchr::memchr_iter(b'\n', bytes) {
277        if newline > 0 && bytes[newline - 1] == b'\r' {
278            crlf = true;
279        } else {
280            lf = true;
281        }
282        if crlf && lf {
283            return LineEnding::Mixed;
284        }
285    }
286    if crlf { LineEnding::Crlf } else { LineEnding::Lf }
287}
288
289pub fn detect_line_ending(content: &str) -> &'static str {
290    // Compatibility function matching the old signature
291    let crlf_count = content.matches("\r\n").count();
292    let lf_count = content.matches('\n').count() - crlf_count;
293
294    if crlf_count > lf_count { "\r\n" } else { "\n" }
295}
296
297pub fn normalize_line_ending<'a>(content: &'a str, target: LineEnding) -> Cow<'a, str> {
298    match target {
299        LineEnding::Lf => {
300            if !content.contains('\r') {
301                Cow::Borrowed(content)
302            } else {
303                Cow::Owned(content.replace("\r\n", "\n"))
304            }
305        }
306        LineEnding::Crlf => {
307            // First normalize everything to LF, then convert to CRLF
308            let normalized = content.replace("\r\n", "\n");
309            Cow::Owned(normalized.replace('\n', "\r\n"))
310        }
311        LineEnding::Mixed => Cow::Borrowed(content),
312    }
313}
314
315pub fn ensure_consistent_line_endings(original: &str, modified: &str) -> String {
316    LineEndingPolicy::new(original).apply(modified).into_owned()
317}
318
319/// The native ending policy of one immutable source revision. A rule that
320/// emits several block edits computes it once, rather than rereading the
321/// entire document for every replacement.
322pub(crate) struct LineEndingPolicy(LineEnding);
323
324impl LineEndingPolicy {
325    pub(crate) fn new(original: &str) -> Self {
326        let ending = detect_line_ending_enum(original);
327        let target = if ending == LineEnding::Mixed {
328            let bytes = original.as_bytes();
329            let mut crlf = 0;
330            let mut lf = 0;
331            for newline in memchr::memchr_iter(b'\n', bytes) {
332                if newline > 0 && bytes[newline - 1] == b'\r' {
333                    crlf += 1;
334                } else {
335                    lf += 1;
336                }
337            }
338            if crlf > lf { LineEnding::Crlf } else { LineEnding::Lf }
339        } else {
340            ending
341        };
342        Self(target)
343    }
344
345    fn apply<'a>(&self, modified: &'a str) -> Cow<'a, str> {
346        if self.0 == detect_line_ending_enum(modified) {
347            Cow::Borrowed(modified)
348        } else {
349            normalize_line_ending(modified, self.0)
350        }
351    }
352
353    pub(crate) fn apply_owned(&self, modified: String) -> String {
354        match self.apply(&modified) {
355            Cow::Borrowed(_) => modified,
356            Cow::Owned(normalized) => normalized,
357        }
358    }
359
360    pub(crate) fn apply_to(&self, modified: &str) -> String {
361        self.apply(modified).into_owned()
362    }
363}
364
365pub fn get_line_ending_str(ending: LineEnding) -> &'static str {
366    match ending {
367        LineEnding::Lf => "\n",
368        LineEnding::Crlf => "\r\n",
369        LineEnding::Mixed => "\n", // Default to LF for mixed
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use proptest::prelude::*;
377
378    fn native_ending(content: &str) -> LineEnding {
379        let mut crlf = 0;
380        let mut lf = 0;
381        let bytes = content.as_bytes();
382        for (i, &byte) in bytes.iter().enumerate() {
383            if byte == b'\n' {
384                if i > 0 && bytes[i - 1] == b'\r' {
385                    crlf += 1
386                } else {
387                    lf += 1
388                }
389            }
390        }
391        match (crlf > 0, lf > 0) {
392            (true, true) => LineEnding::Mixed,
393            (true, false) => LineEnding::Crlf,
394            _ => LineEnding::Lf,
395        }
396    }
397
398    proptest! {
399        #[test]
400        fn source_policy_matches_native_endings_and_replacements(
401            old in prop::collection::vec(prop_oneof![Just('\r'), Just('\n'), any::<char>()], 0..300),
402            new in prop::collection::vec(prop_oneof![Just('\r'), Just('\n'), any::<char>()], 0..300),
403        ) {
404            let old: String = old.into_iter().collect();
405            let new: String = new.into_iter().collect();
406            prop_assert_eq!(detect_line_ending_enum(&old), native_ending(&old));
407            let target = match native_ending(&old) {
408                LineEnding::Mixed => {
409                    let crlf = old.matches("\r\n").count();
410                    if crlf > old.matches('\n').count() - crlf { LineEnding::Crlf } else { LineEnding::Lf }
411                },
412                ending => ending,
413            };
414            let expected = if target == native_ending(&new) {
415                new.clone()
416            } else {
417                match target {
418                    LineEnding::Lf => new.replace("\r\n", "\n"),
419                    LineEnding::Crlf => new.replace("\r\n", "\n").replace('\n', "\r\n"),
420                    LineEnding::Mixed => unreachable!(),
421                }
422            };
423            let policy = LineEndingPolicy::new(&old);
424            prop_assert_eq!(&policy.apply_to(&new), &expected);
425            prop_assert_eq!(&policy.apply_owned(new.clone()), &expected);
426            prop_assert_eq!(ensure_consistent_line_endings(&old, &new), expected);
427        }
428    }
429
430    proptest! {
431        #[test]
432        fn mapping_matches_original_byte_walk(
433            parts in prop::collection::vec(prop_oneof![
434                Just("\r".to_string()), Just("\n".to_string()),
435                Just("\r\n".to_string()), any::<char>().prop_map(|c| c.to_string()),
436            ], 0..200)
437        ) {
438            let original = parts.concat();
439            let mut expected = NormalizedLineEndingMap::default();
440            let mut lfs = Vec::new();
441            let mut bytes = original.bytes().peekable();
442            let mut normalized = 0;
443            while let Some(byte) = bytes.next() {
444                if byte == b'\r' && bytes.peek() == Some(&b'\n') {
445                    expected.crlf_newline_offsets.push(normalized);
446                    bytes.next();
447                } else if byte == b'\n' {
448                    lfs.push(normalized);
449                }
450                normalized += 1;
451            }
452            expected.all_crlf = !expected.crlf_newline_offsets.is_empty() && lfs.is_empty();
453            if !expected.crlf_newline_offsets.is_empty() {
454                expected.mixed_lf_newline_offsets = lfs;
455            }
456            prop_assert_eq!(NormalizedLineEndingMap::new(&original), expected);
457        }
458    }
459
460    #[test]
461    fn test_detect_line_ending_enum() {
462        assert_eq!(detect_line_ending_enum("hello\nworld"), LineEnding::Lf);
463        assert_eq!(detect_line_ending_enum("hello\r\nworld"), LineEnding::Crlf);
464        assert_eq!(detect_line_ending_enum("hello\r\nworld\nmixed"), LineEnding::Mixed);
465        assert_eq!(detect_line_ending_enum("no line endings"), LineEnding::Lf);
466    }
467
468    #[test]
469    fn normalized_line_ending_map_handles_mixed_input() {
470        let original = "a\r\nb\nc\r\n";
471        let map = NormalizedLineEndingMap::new(original);
472
473        // Boundaries surrounding the two CRLF sequences gain one byte each,
474        // while the standalone LF does not.
475        assert_eq!(map.original_offset(1), 1);
476        assert_eq!(map.original_offset(2), 3);
477        assert_eq!(map.original_offset(4), 5);
478        assert_eq!(map.original_offset(6), 8);
479    }
480
481    #[test]
482    fn normalized_line_ending_map_writes_text_with_the_ending_fmt_writes() {
483        let crlf = NormalizedLineEndingMap::new("a\r\nb\r\n");
484        assert_eq!(crlf.original_text_at("x\n\ny\n", 2), "x\r\n\r\ny\r\n");
485        assert_eq!(crlf.original_text_at("no newline", 2), "no newline");
486
487        for original in ["a\nb\n", ""] {
488            let map = NormalizedLineEndingMap::new(original);
489            assert_eq!(map.original_text_at("x\n", 0), "x\n", "{original:?}");
490        }
491
492        // In a mixed file, inserted lines take the ending of the line the
493        // insertion follows, as `fmt` writes them. Normalized "a\nb\nc" has
494        // its newlines at offsets 1 (CRLF) and 3 (LF).
495        let mixed = NormalizedLineEndingMap::new("a\r\nb\nc");
496        assert_eq!(
497            mixed.original_text_at("x\n", 0),
498            "x\r\n",
499            "start of file: first line's ending"
500        );
501        assert_eq!(mixed.original_text_at("x\n", 1), "x\r\n", "end of line a");
502        assert_eq!(
503            mixed.original_text_at("x\n", 2),
504            "x\r\n",
505            "start of line b, after a's CRLF"
506        );
507        assert_eq!(mixed.original_text_at("x\n", 3), "x\n", "end of line b");
508        assert_eq!(mixed.original_text_at("x\n", 4), "x\n", "start of line c, after b's LF");
509        assert_eq!(
510            mixed.original_text_at("x\n", 5),
511            "x\n",
512            "unterminated last line: the ending before it"
513        );
514    }
515
516    #[test]
517    fn line_ending_before_agrees_with_the_map_at_every_offset() {
518        for original in ["a\r\nb\nc", "a\nb\r\n", "\r\n\n\r\nxy\n", "é\r\n日本\nz"] {
519            let map = NormalizedLineEndingMap::new(original);
520            let normalized = normalize_line_ending(original, LineEnding::Lf);
521            for offset in 0..=normalized.len() {
522                assert_eq!(
523                    line_ending_before(original, map.original_offset(offset)),
524                    map.ending_before(offset),
525                    "{original:?} at normalized offset {offset}"
526                );
527            }
528        }
529        assert_eq!(line_ending_before("no newline", 3), "\n");
530    }
531
532    #[test]
533    fn restore_fixed_keeps_uniform_endings_uniform() {
534        let crlf = NormalizedLineEndingMap::new("# A\r\ntext\r\n");
535        assert_eq!(
536            crlf.restore_fixed("# A\ntext\n", "# A\n\ntext\n"),
537            "# A\r\n\r\ntext\r\n"
538        );
539        let lf = NormalizedLineEndingMap::new("# A\ntext\n");
540        assert_eq!(lf.restore_fixed("# A\ntext\n", "# A\n\ntext\n"), "# A\n\ntext\n");
541    }
542
543    /// `restore_fixed` on a mixed original, given as (original, fixed LF text, expected).
544    fn assert_mixed_restore(original: &str, fixed: &str, expected: &str) {
545        let map = NormalizedLineEndingMap::new(original);
546        let normalized = normalize_line_ending(original, LineEnding::Lf);
547        assert_eq!(
548            map.restore_fixed(&normalized, fixed),
549            expected,
550            "{original:?} -> {fixed:?}"
551        );
552    }
553
554    #[test]
555    fn restore_fixed_keeps_each_untouched_lines_ending_in_a_mixed_file() {
556        // Unchanged content comes back byte-identical.
557        assert_mixed_restore("# T\r\nText\n## N\r\n", "# T\nText\n## N\n", "# T\r\nText\n## N\r\n");
558        // Inserted lines take the ending of the line before them.
559        assert_mixed_restore(
560            "# T\r\nText\n## N\r\n",
561            "# T\n\nText\n\n## N\n",
562            "# T\r\n\r\nText\n\n## N\r\n",
563        );
564        // An edited line keeps the ending of the line it replaces.
565        assert_mixed_restore("a  \r\nb\nc  \r\n", "a\nb\nc\n", "a\r\nb\nc\r\n");
566        // A deleted line takes its ending with it.
567        assert_mixed_restore("a\r\nx\ny\r\nb\n", "a\nb\n", "a\r\nb\n");
568        // A blank line inserted before an edited line takes the ending of the
569        // line before it; the edited line keeps its own.
570        assert_mixed_restore(
571            "More\n```\r\ncode\n",
572            "More\n\n```text\ncode\n",
573            "More\n\n```text\r\ncode\n",
574        );
575        // A line inserted at the start takes the first line's ending.
576        assert_mixed_restore("text\r\nmore\n", "# T\n\ntext\nmore\n", "# T\r\n\r\ntext\r\nmore\n");
577        // A final newline added to an unterminated last line takes the ending
578        // of the line before it.
579        assert_mixed_restore("a\n# b\r\nc", "a\n# b\nc\n", "a\n# b\r\nc\r\n");
580        // A line split in two: the inserted break takes the ending of the line
581        // it splits, and the second part keeps that line's own.
582        assert_mixed_restore("x\nlong line\r\ny\n", "x\nlong\nline\ny\n", "x\nlong\r\nline\r\ny\n");
583    }
584
585    #[test]
586    fn normalized_line_ending_map_restores_the_original_bytes() {
587        for original in [
588            "",
589            "no newline",
590            "a\nb\n",
591            "a\r\nb\r\n",
592            "a\r\nb\nc\r\n",
593            "a\nb\r\nc",
594            "\r\n\r\n\n",
595            "lone\rcarriage\r\nreturn\r",
596            "é\r\n日本\n",
597        ] {
598            let normalized = normalize_line_ending(original, LineEnding::Lf);
599            let map = NormalizedLineEndingMap::new(original);
600            assert_eq!(map.restore(&normalized), original, "{original:?}");
601        }
602    }
603
604    #[test]
605    fn test_detect_line_ending() {
606        assert_eq!(detect_line_ending("hello\nworld"), "\n");
607        assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
608        assert_eq!(detect_line_ending("hello\r\nworld\nmixed"), "\n"); // More LF than CRLF
609        assert_eq!(detect_line_ending("no line endings"), "\n");
610    }
611
612    #[test]
613    fn test_normalize_line_ending() {
614        assert_eq!(normalize_line_ending("hello\r\nworld", LineEnding::Lf), "hello\nworld");
615        assert_eq!(
616            normalize_line_ending("hello\nworld", LineEnding::Crlf),
617            "hello\r\nworld"
618        );
619        assert_eq!(
620            normalize_line_ending("hello\r\nworld\nmixed", LineEnding::Lf),
621            "hello\nworld\nmixed"
622        );
623    }
624
625    #[test]
626    fn test_ensure_consistent_line_endings() {
627        let original = "hello\r\nworld";
628        let modified = "hello\nworld\nextra";
629        assert_eq!(
630            ensure_consistent_line_endings(original, modified),
631            "hello\r\nworld\r\nextra"
632        );
633
634        let original = "hello\nworld";
635        let modified = "hello\r\nworld\r\nextra";
636        assert_eq!(
637            ensure_consistent_line_endings(original, modified),
638            "hello\nworld\nextra"
639        );
640    }
641}