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 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 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 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 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 let mut endings: Vec<&'static str> = Vec::with_capacity(new.len());
149 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 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 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
256pub 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 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 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 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 let target_ending = if original_ending == LineEnding::Mixed {
335 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", }
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 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 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 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 assert_mixed_restore("# T\r\nText\n## N\r\n", "# T\nText\n## N\n", "# T\r\nText\n## N\r\n");
466 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 assert_mixed_restore("a \r\nb\nc \r\n", "a\nb\nc\n", "a\r\nb\nc\r\n");
474 assert_mixed_restore("a\r\nx\ny\r\nb\n", "a\nb\n", "a\r\nb\n");
476 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 assert_mixed_restore("text\r\nmore\n", "# T\n\ntext\nmore\n", "# T\r\n\r\ntext\r\nmore\n");
485 assert_mixed_restore("a\n# b\r\nc", "a\n# b\nc\n", "a\n# b\r\nc\r\n");
488 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"); 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}