mbdown 0.1.1

Parser and abstract syntax tree for the MBDown markup language
Documentation
use std::ops::Range;

/// Serialization format used by a Markdown front matter block.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrontMatterKind {
    /// YAML delimited by `---` (or closed by `...`).
    Yaml,
    /// TOML delimited by `+++`.
    Toml,
}

impl FrontMatterKind {
    pub const fn delimiter(self) -> &'static str {
        match self {
            Self::Yaml => "---",
            Self::Toml => "+++",
        }
    }
}

/// Front matter extracted from the beginning of an MBDown document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FrontMatter<'a> {
    kind: FrontMatterKind,
    source: &'a str,
    raw: &'a str,
    span: Range<usize>,
}

impl<'a> FrontMatter<'a> {
    pub fn kind(&self) -> FrontMatterKind {
        self.kind
    }

    /// Raw metadata content between the delimiter lines.
    pub fn source(&self) -> &'a str {
        self.source
    }

    /// Complete front matter including its opening and closing delimiter lines.
    pub fn raw(&self) -> &'a str {
        self.raw
    }

    /// Byte range of the complete front matter in the document source.
    pub fn span(&self) -> Range<usize> {
        self.span.clone()
    }
}

pub(crate) fn split(source: &str) -> (Option<FrontMatter<'_>>, &str) {
    let Some(opening) = source_line(source, 0) else {
        return (None, source);
    };
    let kind = match delimiter(opening.text) {
        Some("---") => FrontMatterKind::Yaml,
        Some("+++") => FrontMatterKind::Toml,
        _ => return (None, source),
    };
    if opening.next == source.len() {
        return (None, source);
    }

    let content_start = opening.next;
    let Some(first_content) = source_line(source, content_start) else {
        return (None, source);
    };
    if first_content.text.trim().is_empty() || closes(kind, delimiter(first_content.text)) {
        return (None, source);
    }
    let mut cursor = content_start;
    while let Some(line) = source_line(source, cursor) {
        let closing = delimiter(line.text);
        if closes(kind, closing) {
            let raw_end = line.next;
            return (
                Some(FrontMatter {
                    kind,
                    source: &source[content_start..line.start],
                    raw: &source[..raw_end],
                    span: 0..raw_end,
                }),
                &source[raw_end..],
            );
        }
        if line.next == source.len() {
            break;
        }
        cursor = line.next;
    }
    (None, source)
}

fn delimiter(line: &str) -> Option<&str> {
    let candidate = line.trim_end_matches([' ', '\t']);
    matches!(candidate, "---" | "..." | "+++").then_some(candidate)
}

fn closes(kind: FrontMatterKind, delimiter: Option<&str>) -> bool {
    match kind {
        FrontMatterKind::Yaml => matches!(delimiter, Some("---" | "...")),
        FrontMatterKind::Toml => delimiter == Some("+++"),
    }
}

struct SourceLine<'a> {
    start: usize,
    next: usize,
    text: &'a str,
}

fn source_line(source: &str, start: usize) -> Option<SourceLine<'_>> {
    if start >= source.len() {
        return None;
    }
    let relative_end = source.as_bytes()[start..]
        .iter()
        .position(|byte| matches!(byte, b'\r' | b'\n'));
    let line_end = relative_end.map_or(source.len(), |offset| start + offset);
    let next = relative_end.map_or(source.len(), |_| {
        line_end
            + if source.as_bytes().get(line_end..line_end + 2) == Some(b"\r\n") {
                2
            } else {
                1
            }
    });
    Some(SourceLine {
        start,
        next,
        text: &source[start..line_end],
    })
}

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

    #[test]
    fn splits_yaml_and_toml_with_original_line_endings() {
        let yaml = "---\r\ntitle: Map\r\n---\r\n# Body";
        let (front_matter, body) = split(yaml);
        let front_matter = front_matter.unwrap();
        assert_eq!(front_matter.kind(), FrontMatterKind::Yaml);
        assert_eq!(front_matter.source(), "title: Map\r\n");
        assert_eq!(front_matter.raw(), "---\r\ntitle: Map\r\n---\r\n");
        assert_eq!(front_matter.span(), 0..front_matter.raw().len());
        assert_eq!(body, "# Body");

        let (front_matter, body) = split("+++\ntitle = \"Map\"\n+++\nBody");
        let front_matter = front_matter.unwrap();
        assert_eq!(front_matter.kind(), FrontMatterKind::Toml);
        assert_eq!(front_matter.source(), "title = \"Map\"\n");
        assert_eq!(body, "Body");
    }

    #[test]
    fn accepts_yaml_document_end_and_lone_cr_lines() {
        let (front_matter, body) = split("---\rtitle: Map\r...\rBody");
        assert_eq!(front_matter.unwrap().source(), "title: Map\r");
        assert_eq!(body, "Body");
    }

    #[test]
    fn other_formats_delimiter_can_remain_metadata_content() {
        let (front_matter, body) = split("---\n+++\n---\nBody");
        assert_eq!(front_matter.unwrap().source(), "+++\n");
        assert_eq!(body, "Body");

        let (front_matter, body) = split("+++\n---\n+++\nBody");
        assert_eq!(front_matter.unwrap().source(), "---\n");
        assert_eq!(body, "Body");
    }

    #[test]
    fn leaves_non_initial_and_unclosed_delimiters_in_the_body() {
        for source in [
            "before\n---\ntitle: Map\n---\n",
            "---\ntitle: Map\n",
            "+++\ntitle = \"Map\"\n---\n",
            "  ---\ntitle: Map\n---\n",
            "---\n---\n",
            "---\n\n---\n",
        ] {
            let (front_matter, body) = split(source);
            assert!(front_matter.is_none(), "{source:?}");
            assert_eq!(body, source);
        }
    }
}