use crate::document::{DocumentError, DocumentResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Delimiter {
Plus,
Dash,
}
impl Delimiter {
fn marker(self) -> &'static str {
match self {
Delimiter::Plus => "+++",
Delimiter::Dash => "---",
}
}
fn label(self) -> &'static str {
match self {
Delimiter::Plus => "TOML frontmatter",
Delimiter::Dash => "YAML frontmatter",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Parts<'a> {
pub pre: &'a str,
pub frontmatter: &'a str,
pub post: &'a str,
}
pub fn split(source: &str, delim: Delimiter) -> DocumentResult<Parts<'_>> {
let marker = delim.marker();
let first_line_end = source.find('\n');
let first_line = match first_line_end {
Some(nl) => &source[..nl],
None => source,
};
if first_line.trim_end() != marker {
return Err(DocumentError::ParseError {
format: delim.label().to_string(),
detail: format!("file does not begin with a `{marker}` frontmatter delimiter line"),
});
}
let Some(nl) = first_line_end else {
return Err(unterminated(delim));
};
let block_start = nl + 1;
let mut idx = block_start;
loop {
let line_end = source[idx..]
.find('\n')
.map(|offset| idx + offset)
.unwrap_or(source.len());
let line = &source[idx..line_end];
if line.trim_end() == marker {
return Ok(Parts {
pre: &source[..block_start],
frontmatter: &source[block_start..idx],
post: &source[idx..],
});
}
if line_end == source.len() {
return Err(unterminated(delim));
}
idx = line_end + 1;
}
}
fn unterminated(delim: Delimiter) -> DocumentError {
DocumentError::ParseError {
format: delim.label().to_string(),
detail: format!(
"unterminated frontmatter: no closing `{}` delimiter",
delim.marker()
),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use super::*;
fn assert_roundtrip(source: &str, parts: &Parts<'_>) {
assert_eq!(
format!("{}{}{}", parts.pre, parts.frontmatter, parts.post),
source,
"spans must concatenate back to the source byte for byte"
);
}
#[test]
fn splits_toml_frontmatter_and_freezes_body() {
let source = "+++\ntitle = \"x\"\n+++\n# Body\n\nprose\n";
let parts = split(source, Delimiter::Plus).unwrap();
assert_eq!(parts.pre, "+++\n");
assert_eq!(parts.frontmatter, "title = \"x\"\n");
assert_eq!(parts.post, "+++\n# Body\n\nprose\n");
assert_roundtrip(source, &parts);
}
#[test]
fn splits_yaml_frontmatter() {
let source = "---\ntitle: x\n---\nbody\n";
let parts = split(source, Delimiter::Dash).unwrap();
assert_eq!(parts.pre, "---\n");
assert_eq!(parts.frontmatter, "title: x\n");
assert_eq!(parts.post, "---\nbody\n");
assert_roundtrip(source, &parts);
}
#[test]
fn empty_block_is_valid() {
let source = "+++\n+++\nbody\n";
let parts = split(source, Delimiter::Plus).unwrap();
assert_eq!(parts.frontmatter, "");
assert_eq!(parts.post, "+++\nbody\n");
assert_roundtrip(source, &parts);
}
#[test]
fn frontmatter_only_no_body() {
let source = "+++\ntitle = \"x\"\n+++\n";
let parts = split(source, Delimiter::Plus).unwrap();
assert_eq!(parts.frontmatter, "title = \"x\"\n");
assert_eq!(parts.post, "+++\n");
assert_roundtrip(source, &parts);
}
#[test]
fn preserves_crlf_line_endings() {
let source = "+++\r\ntitle = \"x\"\r\n+++\r\nbody\r\n";
let parts = split(source, Delimiter::Plus).unwrap();
assert_eq!(parts.pre, "+++\r\n");
assert_eq!(parts.frontmatter, "title = \"x\"\r\n");
assert_eq!(parts.post, "+++\r\nbody\r\n");
assert_roundtrip(source, &parts);
}
#[test]
fn missing_opening_delimiter_errors() {
let err = split("# Just a markdown heading\n", Delimiter::Plus).unwrap_err();
assert!(matches!(err, DocumentError::ParseError { .. }));
}
#[test]
fn wrong_delimiter_errors() {
let err = split("+++\ntitle = \"x\"\n+++\n", Delimiter::Dash).unwrap_err();
assert!(matches!(err, DocumentError::ParseError { .. }));
}
#[test]
fn unterminated_block_errors() {
let err = split("+++\ntitle = \"x\"\nno closing fence\n", Delimiter::Plus).unwrap_err();
assert!(matches!(err, DocumentError::ParseError { .. }));
}
#[test]
fn indented_fence_is_not_a_close() {
let err = split("+++\ntitle = \"x\"\n +++\n", Delimiter::Plus).unwrap_err();
assert!(matches!(err, DocumentError::ParseError { .. }));
}
}