base_markdown/util/
line_ending.rs1use alloc::{str::FromStr, string::String};
7use core::ops::Range;
8
9pub(crate) fn line_endings(value: &str) -> impl Iterator<Item = Range<usize>> + '_ {
11 let bytes = value.as_bytes();
12 let mut at = 0;
13 core::iter::from_fn(move || {
14 let start = at + memchr::memchr2(b'\r', b'\n', &bytes[at..])?;
15 at = start + 1;
16 if bytes[start] == b'\r' && bytes.get(at) == Some(&b'\n') {
17 at += 1;
18 }
19 Some(start..at)
20 })
21}
22
23#[inline]
25pub(crate) fn terminal_ending_start(bytes: &[u8], mut end: usize) -> usize {
26 if bytes.get(end.wrapping_sub(1)) == Some(&b'\n') {
27 end -= 1;
28 }
29 if bytes.get(end.wrapping_sub(1)) == Some(&b'\r') {
30 end -= 1;
31 }
32 end
33}
34
35pub(crate) fn trim_terminal_ending(value: &str) -> &str {
37 &value[..terminal_ending_start(value.as_bytes(), value.len())]
38}
39
40#[derive(Clone, Debug, Default, Eq, PartialEq)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57pub enum LineEnding {
58 #[cfg_attr(feature = "serde", serde(rename = "\r\n"))]
67 CarriageReturnLineFeed,
68 #[cfg_attr(feature = "serde", serde(rename = "\r"))]
77 CarriageReturn,
78 #[default]
87 #[cfg_attr(feature = "serde", serde(rename = "\n"))]
88 LineFeed,
89}
90
91impl LineEnding {
92 pub(crate) fn from_source(source: &str) -> Option<Self> {
94 let bytes = source.as_bytes();
95 let index = memchr::memchr2(b'\r', b'\n', bytes)?;
96 Some(if bytes[index] == b'\n' {
97 Self::LineFeed
98 } else if bytes.get(index + 1) == Some(&b'\n') {
99 Self::CarriageReturnLineFeed
100 } else {
101 Self::CarriageReturn
102 })
103 }
104
105 #[must_use]
107 pub fn as_str(&self) -> &str {
108 match self {
109 LineEnding::CarriageReturnLineFeed => "\r\n",
110 LineEnding::CarriageReturn => "\r",
111 LineEnding::LineFeed => "\n",
112 }
113 }
114}
115
116impl FromStr for LineEnding {
117 type Err = String;
118
119 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 match s {
126 "\r\n" => Ok(LineEnding::CarriageReturnLineFeed),
127 "\r" => Ok(LineEnding::CarriageReturn),
128 "\n" => Ok(LineEnding::LineFeed),
129 _ => Err("Expected CR, LF, or CRLF".into()),
130 }
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn preserves_complete_physical_line_endings() {
140 let value = "é\r\nα\rb\n";
141 assert_eq!(
142 line_endings(value)
143 .map(|range| &value[range])
144 .collect::<alloc::vec::Vec<_>>(),
145 ["\r\n", "\r", "\n"]
146 );
147 }
148
149 #[test]
150 fn trims_only_one_terminal_physical_line_ending() {
151 for (source, expected) in [
152 ("", ""),
153 ("é", "é"),
154 ("\n", ""),
155 ("\r", ""),
156 ("\r\n", ""),
157 ("é\n", "é"),
158 ("é\r", "é"),
159 ("é\r\n", "é"),
160 ("é\n\n", "é\n"),
161 ("é\r\r\n", "é\r"),
162 ("é\n\r", "é\n"),
163 ] {
164 assert_eq!(trim_terminal_ending(source), expected);
165 let with_suffix = alloc::format!("{source}suffix");
166 assert_eq!(
167 terminal_ending_start(with_suffix.as_bytes(), source.len()),
168 expected.len(),
169 );
170 }
171 }
172
173 #[test]
174 fn detects_the_first_source_line_ending() {
175 for (source, expected) in [
176 ("plain é", None),
177 ("é\rfirst\n", Some(LineEnding::CarriageReturn)),
178 ("\n\r", Some(LineEnding::LineFeed)),
179 ("é\r\nfirst\r", Some(LineEnding::CarriageReturnLineFeed)),
180 ] {
181 assert_eq!(LineEnding::from_source(source), expected);
182 }
183 }
184
185 #[test]
186 fn test_line_ending() {
187 assert_eq!(
188 "\r".parse(),
189 Ok(LineEnding::CarriageReturn),
190 "should support turning a string into a carriage return"
191 );
192 assert_eq!(
193 LineEnding::CarriageReturn.as_str(),
194 "\r",
195 "should support turning a carriage return into a string"
196 );
197
198 assert_eq!(
199 "\n".parse(),
200 Ok(LineEnding::LineFeed),
201 "should support turning a string into a line feed"
202 );
203 assert_eq!(
204 LineEnding::LineFeed.as_str(),
205 "\n",
206 "should support turning a line feed into a string"
207 );
208
209 assert_eq!(
210 "\r\n".parse(),
211 Ok(LineEnding::CarriageReturnLineFeed),
212 "should support turning a string into a carriage return + line feed"
213 );
214 assert_eq!(
215 LineEnding::CarriageReturnLineFeed.as_str(),
216 "\r\n",
217 "should support turning a carriage return + line feed into a string"
218 );
219
220 assert_eq!(
221 "aaa".parse::<LineEnding>(),
222 Err("Expected CR, LF, or CRLF".into()),
223 "should error when parsing a non-eol"
224 );
225 }
226}