rumdl 0.2.63

A fast Markdown linter and formatter written in Rust
Documentation
use std::borrow::Cow;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineEnding {
    Lf,
    Crlf,
    Mixed,
}

/// Maps byte offsets in LF-normalized content back to the original input.
///
/// rumdl normalizes CRLF to LF while linting. Machine-readable fixes, however,
/// must address the bytes supplied by the caller, including mixed-line-ending
/// input. Each entry records the normalized offset of a newline whose preceding
/// carriage return was removed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NormalizedLineEndingMap {
    crlf_newline_offsets: Vec<usize>,
}

impl NormalizedLineEndingMap {
    pub fn new(original: &str) -> Self {
        let bytes = original.as_bytes();
        let mut crlf_newline_offsets = Vec::new();
        let mut original_offset = 0;
        let mut normalized_offset = 0;

        while original_offset < bytes.len() {
            if bytes[original_offset] == b'\r'
                && original_offset + 1 < bytes.len()
                && bytes[original_offset + 1] == b'\n'
            {
                crlf_newline_offsets.push(normalized_offset);
                original_offset += 2;
                normalized_offset += 1;
            } else {
                original_offset += 1;
                normalized_offset += 1;
            }
        }

        Self { crlf_newline_offsets }
    }

    /// Convert a byte boundary in normalized content to the corresponding byte
    /// boundary in the original input.
    pub fn original_offset(&self, normalized_offset: usize) -> usize {
        normalized_offset
            + self
                .crlf_newline_offsets
                .partition_point(|newline_offset| *newline_offset < normalized_offset)
    }
}

pub fn detect_line_ending_enum(content: &str) -> LineEnding {
    let bytes = content.as_bytes();
    let mut has_crlf = false;
    let mut has_standalone_lf = false;
    let mut i = 0;

    while i < bytes.len() {
        if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
            has_crlf = true;
            i += 2;
        } else if bytes[i] == b'\n' {
            has_standalone_lf = true;
            i += 1;
        } else {
            i += 1;
        }
        // Early exit once both types are found
        if has_crlf && has_standalone_lf {
            return LineEnding::Mixed;
        }
    }

    match (has_crlf, has_standalone_lf) {
        (true, true) => LineEnding::Mixed,
        (true, false) => LineEnding::Crlf,
        (false, _) => LineEnding::Lf,
    }
}

pub fn detect_line_ending(content: &str) -> &'static str {
    // Compatibility function matching the old signature
    let crlf_count = content.matches("\r\n").count();
    let lf_count = content.matches('\n').count() - crlf_count;

    if crlf_count > lf_count { "\r\n" } else { "\n" }
}

pub fn normalize_line_ending<'a>(content: &'a str, target: LineEnding) -> Cow<'a, str> {
    match target {
        LineEnding::Lf => {
            if !content.contains('\r') {
                Cow::Borrowed(content)
            } else {
                Cow::Owned(content.replace("\r\n", "\n"))
            }
        }
        LineEnding::Crlf => {
            // First normalize everything to LF, then convert to CRLF
            let normalized = content.replace("\r\n", "\n");
            Cow::Owned(normalized.replace('\n', "\r\n"))
        }
        LineEnding::Mixed => Cow::Borrowed(content),
    }
}

pub fn ensure_consistent_line_endings(original: &str, modified: &str) -> String {
    let original_ending = detect_line_ending_enum(original);

    // For mixed line endings, normalize to the most common one (like detect_line_ending does)
    let target_ending = if original_ending == LineEnding::Mixed {
        // Use the same logic as detect_line_ending: prefer the more common one
        let crlf_count = original.matches("\r\n").count();
        let lf_count = original.matches('\n').count() - crlf_count;
        if crlf_count > lf_count {
            LineEnding::Crlf
        } else {
            LineEnding::Lf
        }
    } else {
        original_ending
    };

    let modified_ending = detect_line_ending_enum(modified);

    if target_ending != modified_ending {
        normalize_line_ending(modified, target_ending).into_owned()
    } else {
        modified.to_string()
    }
}

pub fn get_line_ending_str(ending: LineEnding) -> &'static str {
    match ending {
        LineEnding::Lf => "\n",
        LineEnding::Crlf => "\r\n",
        LineEnding::Mixed => "\n", // Default to LF for mixed
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_detect_line_ending_enum() {
        assert_eq!(detect_line_ending_enum("hello\nworld"), LineEnding::Lf);
        assert_eq!(detect_line_ending_enum("hello\r\nworld"), LineEnding::Crlf);
        assert_eq!(detect_line_ending_enum("hello\r\nworld\nmixed"), LineEnding::Mixed);
        assert_eq!(detect_line_ending_enum("no line endings"), LineEnding::Lf);
    }

    #[test]
    fn normalized_line_ending_map_handles_mixed_input() {
        let original = "a\r\nb\nc\r\n";
        let map = NormalizedLineEndingMap::new(original);

        // Boundaries surrounding the two CRLF sequences gain one byte each,
        // while the standalone LF does not.
        assert_eq!(map.original_offset(1), 1);
        assert_eq!(map.original_offset(2), 3);
        assert_eq!(map.original_offset(4), 5);
        assert_eq!(map.original_offset(6), 8);
    }

    #[test]
    fn test_detect_line_ending() {
        assert_eq!(detect_line_ending("hello\nworld"), "\n");
        assert_eq!(detect_line_ending("hello\r\nworld"), "\r\n");
        assert_eq!(detect_line_ending("hello\r\nworld\nmixed"), "\n"); // More LF than CRLF
        assert_eq!(detect_line_ending("no line endings"), "\n");
    }

    #[test]
    fn test_normalize_line_ending() {
        assert_eq!(normalize_line_ending("hello\r\nworld", LineEnding::Lf), "hello\nworld");
        assert_eq!(
            normalize_line_ending("hello\nworld", LineEnding::Crlf),
            "hello\r\nworld"
        );
        assert_eq!(
            normalize_line_ending("hello\r\nworld\nmixed", LineEnding::Lf),
            "hello\nworld\nmixed"
        );
    }

    #[test]
    fn test_ensure_consistent_line_endings() {
        let original = "hello\r\nworld";
        let modified = "hello\nworld\nextra";
        assert_eq!(
            ensure_consistent_line_endings(original, modified),
            "hello\r\nworld\r\nextra"
        );

        let original = "hello\nworld";
        let modified = "hello\r\nworld\r\nextra";
        assert_eq!(
            ensure_consistent_line_endings(original, modified),
            "hello\nworld\nextra"
        );
    }
}