1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
|
//! Line ending detection and normalisation (MJB-HLR-003).
//!
//! The rope stores LF internally regardless of what the file used; the original
//! terminator is recorded and reapplied on write, so opening and saving a CRLF
//! file does not silently rewrite every line.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineEnding {
#[default]
Lf,
Crlf,
Cr,
}
impl LineEnding {
pub fn as_str(self) -> &'static str {
match self {
LineEnding::Lf => "\n",
LineEnding::Crlf => "\r\n",
LineEnding::Cr => "\r",
}
}
/// The platform default, used when a buffer contains no terminator at all.
pub fn platform_default() -> Self {
if cfg!(windows) {
LineEnding::Crlf
} else {
LineEnding::Lf
}
}
/// MJB-LLR-114: the line ending of the first terminator present.
///
/// Takes `&str` rather than a `RopeSlice`: detection runs on freshly
/// decoded text before the rope is built, and accepting a rope would force
/// the caller to construct one purely to answer this question.
///
/// Scans bytes, not chars. CR and LF are ASCII and cannot appear as a
/// continuation byte of a multi-byte sequence, so a byte scan is both
/// correct and free of UTF-8 decoding.
pub fn detect(text: &str) -> Self {
match text.as_bytes().iter().position(|&b| b == b'\n' || b == b'\r') {
Some(i) if text.as_bytes()[i] == b'\n' => LineEnding::Lf,
// A CR followed by LF is CRLF; a CR followed by anything else, or
// by nothing at all, stood alone.
Some(i) if text.as_bytes().get(i + 1) == Some(&b'\n') => LineEnding::Crlf,
Some(_) => LineEnding::Cr,
// MJB-LLR-114: no terminator anywhere.
None => Self::platform_default(),
}
}
}
/// Rewrite every terminator in `text` (assumed LF-normalised) as `ending`.
pub fn apply(text: &str, ending: LineEnding) -> String {
match ending {
LineEnding::Lf => text.to_owned(),
LineEnding::Crlf => text.replace('\n', "\r\n"),
LineEnding::Cr => text.replace('\n', "\r"),
}
}
/// Normalise CRLF and lone CR to LF for storage in the rope.
pub fn normalize(text: &str) -> String {
if !text.contains('\r') {
return text.to_owned();
}
text.replace("\r\n", "\n").replace('\r', "\n")
}
/// Append a terminator when `text` is non-empty and lacks one (MJB-LLR-185).
pub fn with_final_newline(text: &str) -> String {
if text.is_empty() || text.ends_with('\n') {
text.to_owned()
} else {
format!("{text}\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn detect(s: &str) -> LineEnding {
LineEnding::detect(s)
}
#[test]
fn mjb_llr_114_detects_lf() {
assert_eq!(detect("a\nb\n"), LineEnding::Lf);
}
#[test]
fn mjb_llr_114_detects_crlf() {
assert_eq!(detect("a\r\nb\r\n"), LineEnding::Crlf);
}
#[test]
fn mjb_llr_114_detects_lone_cr() {
assert_eq!(detect("a\rb\r"), LineEnding::Cr);
}
#[test]
fn mjb_llr_114_falls_back_to_platform_default() {
assert_eq!(detect("no terminator"), LineEnding::platform_default());
assert_eq!(detect(""), LineEnding::platform_default());
}
#[test]
fn mjb_llr_114_first_terminator_decides() {
// Mixed endings: the first one wins, as documented.
assert_eq!(detect("a\nb\r\n"), LineEnding::Lf);
assert_eq!(detect("a\r\nb\n"), LineEnding::Crlf);
}
#[test]
fn normalize_collapses_to_lf() {
assert_eq!(normalize("a\r\nb\r\n"), "a\nb\n");
assert_eq!(normalize("a\rb\r"), "a\nb\n");
assert_eq!(normalize("a\nb\n"), "a\nb\n");
}
#[test]
fn mjb_llr_115_apply_restores_original_ending() {
assert_eq!(apply("a\nb\n", LineEnding::Crlf), "a\r\nb\r\n");
assert_eq!(apply("a\nb\n", LineEnding::Cr), "a\rb\r");
assert_eq!(apply("a\nb\n", LineEnding::Lf), "a\nb\n");
}
#[test]
fn crlf_round_trips_through_normalize_and_apply() {
let original = "one\r\ntwo\r\nthree\r\n";
let stored = normalize(original);
assert_eq!(apply(&stored, LineEnding::Crlf), original);
}
#[test]
fn mjb_llr_185_final_newline_only_added_when_missing() {
assert_eq!(with_final_newline("a"), "a\n");
assert_eq!(with_final_newline("a\n"), "a\n", "not doubled");
assert_eq!(with_final_newline(""), "", "empty buffer stays empty");
}
}
|