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