Skip to main content

base_markdown/util/
line_ending.rs

1// Released under the MIT License.
2// Copyright, 2022, by Bernhard Berger.
3// Copyright, 2022-2025, by Titus Wormer.
4// Copyright, 2026, by Samuel Williams.
5
6use alloc::{str::FromStr, string::String};
7use core::ops::Range;
8
9/// Complete physical line endings, treating CRLF as one range.
10pub(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/// Start of one terminal physical line ending before `end`, or `end` itself.
24#[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
35/// Remove one terminal physical line ending, preserving preceding line endings.
36pub(crate) fn trim_terminal_ending(value: &str) -> &str {
37    &value[..terminal_ending_start(value.as_bytes(), value.len())]
38}
39
40/// Type of line endings in markdown.
41///
42/// Particularly when working with Windows, you might want to use
43/// `LineEnding::CarriageReturnLineFeed`.
44///
45/// ## Examples
46///
47/// ```
48/// use base_markdown::LineEnding;
49/// # fn main() {
50///
51/// // Use a CR + LF combination:
52/// let crlf = LineEnding::CarriageReturnLineFeed;
53/// # }
54/// ```
55#[derive(Clone, Debug, Default, Eq, PartialEq)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57pub enum LineEnding {
58    /// Both a carriage return (`\r`) and a line feed (`\n`).
59    ///
60    /// ## Example
61    ///
62    /// ```markdown
63    /// a␍␊
64    /// b
65    /// ```
66    #[cfg_attr(feature = "serde", serde(rename = "\r\n"))]
67    CarriageReturnLineFeed,
68    /// Sole carriage return (`\r`).
69    ///
70    /// ## Example
71    ///
72    /// ```markdown
73    /// a␍
74    /// b
75    /// ```
76    #[cfg_attr(feature = "serde", serde(rename = "\r"))]
77    CarriageReturn,
78    /// Sole line feed (`\n`).
79    ///
80    /// ## Example
81    ///
82    /// ```markdown
83    /// a␊
84    /// b
85    /// ```
86    #[default]
87    #[cfg_attr(feature = "serde", serde(rename = "\n"))]
88    LineFeed,
89}
90
91impl LineEnding {
92    /// Detect the first authored line ending without decoding unrelated UTF-8 text.
93    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    /// Turn the line ending into a [str].
106    #[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    /// Turn a string into a line ending.
120    ///
121    /// ## Panics
122    ///
123    /// Panics if `code` is not `\r\n`, `\r`, or `\n`.
124    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}