Skip to main content

detect_newline/
lib.rs

1//! # detect-newline — detect and normalize line endings
2//!
3//! Detect which line ending a string uses (`\n`, `\r\n`, or `\r`), normalize a
4//! string to one style, count the styles, iterate lines (splitting on all three),
5//! and trim trailing newlines. Zero dependencies and `no_std`. A Rust take on
6//! Node's [`detect-newline`](https://www.npmjs.com/package/detect-newline).
7//!
8//! ```
9//! use detect_newline::{detect, dominant, to_lf, Newline};
10//!
11//! assert_eq!(detect("a\r\nb"), Some(Newline::CrLf));   // first newline found
12//! assert_eq!(dominant("a\nb\nc\r\nd"), Some(Newline::Lf)); // most common
13//! assert_eq!(to_lf("a\r\nb\rc"), "a\nb\nc");            // normalize
14//! ```
15//!
16//! `\r\n` is always treated as a single line ending, never as a `\r` followed by a
17//! `\n`. [`detect`] returns the first ending found; [`dominant`] returns the most
18//! frequent (ties broken by which occurs first).
19
20#![no_std]
21#![doc(html_root_url = "https://docs.rs/detect-newline/0.1.0")]
22
23extern crate alloc;
24
25use alloc::string::String;
26use core::iter::FusedIterator;
27
28// Compile-test the README's examples as part of `cargo test`.
29#[cfg(doctest)]
30#[doc = include_str!("../README.md")]
31struct ReadmeDoctests;
32
33/// A line-ending style.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35pub enum Newline {
36    /// Unix line feed, `\n`. The default.
37    #[default]
38    Lf,
39    /// Windows carriage return + line feed, `\r\n`.
40    CrLf,
41    /// Classic Mac OS carriage return, `\r`.
42    Cr,
43}
44
45impl Newline {
46    /// The characters this line ending is written as.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Newline::Lf => "\n",
51            Newline::CrLf => "\r\n",
52            Newline::Cr => "\r",
53        }
54    }
55}
56
57/// How many of each line-ending style a string contains. See [`count`].
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub struct Counts {
60    /// Number of `\n` line endings.
61    pub lf: usize,
62    /// Number of `\r\n` line endings.
63    pub crlf: usize,
64    /// Number of lone `\r` line endings.
65    pub cr: usize,
66}
67
68/// Detect the first line ending in `text`, or `None` if it has none.
69///
70/// ```
71/// assert_eq!(detect_newline::detect("a\r\nb"), Some(detect_newline::Newline::CrLf));
72/// ```
73#[must_use]
74pub fn detect(text: &str) -> Option<Newline> {
75    let bytes = text.as_bytes();
76    let mut i = 0;
77    while i < bytes.len() {
78        match bytes[i] {
79            b'\r' => {
80                return Some(if bytes.get(i + 1) == Some(&b'\n') {
81                    Newline::CrLf
82                } else {
83                    Newline::Cr
84                });
85            }
86            b'\n' => return Some(Newline::Lf),
87            _ => i += 1,
88        }
89    }
90    None
91}
92
93/// Count each line-ending style in `text`.
94#[must_use]
95pub fn count(text: &str) -> Counts {
96    let bytes = text.as_bytes();
97    let mut c = Counts::default();
98    let mut i = 0;
99    while i < bytes.len() {
100        match bytes[i] {
101            b'\r' => {
102                if bytes.get(i + 1) == Some(&b'\n') {
103                    c.crlf += 1;
104                    i += 2;
105                } else {
106                    c.cr += 1;
107                    i += 1;
108                }
109            }
110            b'\n' => {
111                c.lf += 1;
112                i += 1;
113            }
114            _ => i += 1,
115        }
116    }
117    c
118}
119
120/// Detect the most frequent line ending in `text`, or `None` if it has none.
121///
122/// Ties are broken by which style occurs first.
123#[must_use]
124pub fn dominant(text: &str) -> Option<Newline> {
125    let bytes = text.as_bytes();
126    // (count, first-occurrence index) per style.
127    let mut stats = [
128        (0usize, usize::MAX, Newline::Lf),
129        (0usize, usize::MAX, Newline::CrLf),
130        (0usize, usize::MAX, Newline::Cr),
131    ];
132    let mut i = 0;
133    while i < bytes.len() {
134        match bytes[i] {
135            b'\r' => {
136                let (slot, step) = if bytes.get(i + 1) == Some(&b'\n') {
137                    (1, 2)
138                } else {
139                    (2, 1)
140                };
141                bump(&mut stats[slot], i);
142                i += step;
143            }
144            b'\n' => {
145                bump(&mut stats[0], i);
146                i += 1;
147            }
148            _ => i += 1,
149        }
150    }
151
152    let mut best: Option<(usize, usize, Newline)> = None;
153    for &(c, idx, nl) in &stats {
154        if c == 0 {
155            continue;
156        }
157        let better = match best {
158            None => true,
159            Some((bc, bi, _)) => c > bc || (c == bc && idx < bi),
160        };
161        if better {
162            best = Some((c, idx, nl));
163        }
164    }
165    best.map(|(_, _, nl)| nl)
166}
167
168/// Record one occurrence of a style at byte index `at`.
169fn bump(slot: &mut (usize, usize, Newline), at: usize) {
170    if slot.0 == 0 {
171        slot.1 = at;
172    }
173    slot.0 += 1;
174}
175
176/// Replace every line ending in `text` with `to`.
177///
178/// ```
179/// use detect_newline::{normalize, Newline};
180/// assert_eq!(normalize("a\r\nb\rc", Newline::Lf), "a\nb\nc");
181/// ```
182#[must_use]
183pub fn normalize(text: &str, to: Newline) -> String {
184    let nl = to.as_str();
185    let mut out = String::with_capacity(text.len());
186    let mut rest = text;
187    loop {
188        match rest.find(['\n', '\r']) {
189            None => {
190                out.push_str(rest);
191                break;
192            }
193            Some(i) => {
194                out.push_str(&rest[..i]);
195                out.push_str(nl);
196                let bytes = rest.as_bytes();
197                let adv = if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
198                    i + 2
199                } else {
200                    i + 1
201                };
202                rest = &rest[adv..];
203            }
204        }
205    }
206    out
207}
208
209/// Normalize every line ending in `text` to `\n`.
210#[must_use]
211pub fn to_lf(text: &str) -> String {
212    normalize(text, Newline::Lf)
213}
214
215/// Normalize every line ending in `text` to `\r\n`.
216#[must_use]
217pub fn to_crlf(text: &str) -> String {
218    normalize(text, Newline::CrLf)
219}
220
221/// Normalize every line ending in `text` to `\r`.
222#[must_use]
223pub fn to_cr(text: &str) -> String {
224    normalize(text, Newline::Cr)
225}
226
227/// Whether `text` ends with a line ending.
228#[must_use]
229pub fn has_trailing_newline(text: &str) -> bool {
230    text.ends_with(['\n', '\r'])
231}
232
233/// Strip a single trailing line ending (`\r\n`, `\n`, or `\r`) from `text`.
234#[must_use]
235pub fn strip_trailing_newline(text: &str) -> &str {
236    if let Some(s) = text.strip_suffix("\r\n") {
237        s
238    } else if let Some(s) = text.strip_suffix(['\n', '\r']) {
239        s
240    } else {
241        text
242    }
243}
244
245/// Iterate the lines of `text`, splitting on `\n`, `\r\n`, and lone `\r`.
246///
247/// Like [`str::lines`] but also splitting on a lone `\r`; a single trailing line
248/// ending does not yield a final empty line.
249///
250/// ```
251/// use detect_newline::lines;
252/// assert_eq!(lines("a\r\nb\rc").collect::<Vec<_>>(), ["a", "b", "c"]);
253/// ```
254#[must_use]
255pub fn lines(text: &str) -> Lines<'_> {
256    Lines {
257        rest: text,
258        done: false,
259    }
260}
261
262/// Iterator over the lines of a string. Created by [`lines`].
263#[derive(Debug, Clone)]
264pub struct Lines<'a> {
265    rest: &'a str,
266    done: bool,
267}
268
269impl<'a> Iterator for Lines<'a> {
270    type Item = &'a str;
271
272    fn next(&mut self) -> Option<&'a str> {
273        if self.done {
274            return None;
275        }
276        match self.rest.find(['\n', '\r']) {
277            None => {
278                self.done = true;
279                if self.rest.is_empty() {
280                    None
281                } else {
282                    let line = self.rest;
283                    self.rest = "";
284                    Some(line)
285                }
286            }
287            Some(i) => {
288                let line = &self.rest[..i];
289                let bytes = self.rest.as_bytes();
290                let adv = if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
291                    i + 2
292                } else {
293                    i + 1
294                };
295                let after = &self.rest[adv..];
296                if after.is_empty() {
297                    self.done = true; // trailing line ending → no empty final line
298                }
299                self.rest = after;
300                Some(line)
301            }
302        }
303    }
304}
305
306impl FusedIterator for Lines<'_> {}