1use 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 mixed_lf_newline_offsets: Vec<usize>,
23 all_crlf: bool,
26}
27
28impl NormalizedLineEndingMap {
29 pub fn new(original: &str) -> Self {
30 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 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 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 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 let mut endings: Vec<&'static str> = Vec::with_capacity(new.len());
142 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 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 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
249pub 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 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 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 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
319pub(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", }
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 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 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 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 assert_mixed_restore("# T\r\nText\n## N\r\n", "# T\nText\n## N\n", "# T\r\nText\n## N\r\n");
558 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 assert_mixed_restore("a \r\nb\nc \r\n", "a\nb\nc\n", "a\r\nb\nc\r\n");
566 assert_mixed_restore("a\r\nx\ny\r\nb\n", "a\nb\n", "a\r\nb\n");
568 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 assert_mixed_restore("text\r\nmore\n", "# T\n\ntext\nmore\n", "# T\r\n\r\ntext\r\nmore\n");
577 assert_mixed_restore("a\n# b\r\nc", "a\n# b\nc\n", "a\n# b\r\nc\r\n");
580 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"); 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}