//! 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"); } }