use std::ops::Range;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrontMatterKind {
Yaml,
Toml,
}
impl FrontMatterKind {
pub const fn delimiter(self) -> &'static str {
match self {
Self::Yaml => "---",
Self::Toml => "+++",
}
}
}
#[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
}
pub fn source(&self) -> &'a str {
self.source
}
pub fn raw(&self) -> &'a str {
self.raw
}
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);
}
}
}