use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::meta::{self, Value};
pub use fig::EmbedType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetaCarrier {
Fenced(EmbedType),
WholeFile(fig::Format),
}
impl MetaCarrier {
pub fn format(&self) -> fig::Format {
match self {
MetaCarrier::Fenced(kind) => kind.inner_format(),
MetaCarrier::WholeFile(format) => *format,
}
}
}
pub fn whole_file_format(path: &Path) -> Option<fig::Format> {
match path.extension()?.to_str()? {
#[cfg(feature = "yaml")]
"yaml" | "yml" => Some(fig::Format::Yaml),
#[cfg(feature = "json")]
"json" => Some(fig::Format::Json),
#[cfg(feature = "toml")]
"toml" => Some(fig::Format::Toml),
#[cfg(feature = "fig-lang")]
"fig" | "figl" => Some(fig::Format::Fig),
_ => None,
}
}
pub fn require_whole_file(path: &Path, carrier: MetaCarrier) -> Result<fig::Format> {
match carrier {
MetaCarrier::WholeFile(format) => Ok(format),
MetaCarrier::Fenced(_) => Err(crate::error::Error::MarkdownStore(path.to_path_buf())),
}
}
pub fn is_opaque_payload(path: &Path) -> bool {
crate::content::ContentFormat::from_extension(path).is_none()
&& whole_file_format(path).is_none()
}
pub fn whole_file_extension(format: fig::Format) -> &'static str {
match format {
#[cfg(feature = "json")]
fig::Format::Json => "json",
#[cfg(feature = "toml")]
fig::Format::Toml => "toml",
#[cfg(feature = "fig-lang")]
fig::Format::Fig => "figl",
_ => "yaml",
}
}
pub fn frontmatter_carrier(format: fig::Format) -> MetaCarrier {
let embed = match format {
#[cfg(feature = "json")]
fig::Format::Json => EmbedType::FrontmatterJson,
#[cfg(feature = "toml")]
fig::Format::Toml => EmbedType::PlusToml,
#[cfg(feature = "fig-lang")]
fig::Format::Fig => EmbedType::FrontmatterFig,
_ => EmbedType::FrontmatterYaml,
};
MetaCarrier::Fenced(embed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbedStyle {
Delimited,
CodeBlock,
HtmlScript,
HtmlCode,
Separate,
}
impl EmbedStyle {
pub fn as_config_str(self) -> &'static str {
match self {
EmbedStyle::Delimited => "delimited",
EmbedStyle::CodeBlock => "code_block",
EmbedStyle::HtmlScript => "html_script",
EmbedStyle::HtmlCode => "html_code",
EmbedStyle::Separate => "separate",
}
}
pub fn from_config_str(value: &str) -> Option<Self> {
Some(match value {
"delimited" => EmbedStyle::Delimited,
"code_block" => EmbedStyle::CodeBlock,
"html_script" => EmbedStyle::HtmlScript,
"html_code" => EmbedStyle::HtmlCode,
"separate" => EmbedStyle::Separate,
_ => return None,
})
}
}
pub fn embed_style_of(kind: EmbedType) -> EmbedStyle {
use EmbedType as E;
match kind {
E::FrontmatterYaml
| E::FrontmatterJson
| E::PlusToml
| E::MdFrontmatterJson
| E::MdFrontmatterToml
| E::MdFrontmatterFig => EmbedStyle::Delimited,
E::EndmatterYaml | E::FencedYaml | E::FencedJson | E::FencedToml | E::FrontmatterFig => {
EmbedStyle::CodeBlock
}
E::HtmlScriptYaml | E::HtmlScriptJson | E::HtmlScriptToml | E::HtmlScriptFig => {
EmbedStyle::HtmlScript
}
E::HtmlCodeYaml | E::HtmlCodeJson | E::HtmlCodeToml | E::HtmlCodeFig => {
EmbedStyle::HtmlCode
}
}
}
pub fn embed_carrier(style: EmbedStyle, format: fig::Format) -> Option<MetaCarrier> {
use EmbedType as E;
use fig::Format as F;
let is_json = matches!(format, F::Json | F::Jsonc | F::Json5);
let kind = match style {
EmbedStyle::Separate => return Some(MetaCarrier::WholeFile(format)),
EmbedStyle::Delimited => match format {
F::Yaml => E::FrontmatterYaml,
F::Toml => E::PlusToml,
_ if is_json => E::FrontmatterJson,
_ => return None,
},
EmbedStyle::CodeBlock => match format {
F::Yaml => E::FencedYaml,
F::Toml => E::FencedToml,
F::Fig => E::FrontmatterFig,
_ if is_json => E::FencedJson,
_ => return None,
},
EmbedStyle::HtmlScript => match format {
F::Yaml => E::HtmlScriptYaml,
F::Toml => E::HtmlScriptToml,
F::Fig => E::HtmlScriptFig,
_ if is_json => E::HtmlScriptJson,
_ => return None,
},
EmbedStyle::HtmlCode => match format {
F::Yaml => E::HtmlCodeYaml,
F::Toml => E::HtmlCodeToml,
F::Fig => E::HtmlCodeFig,
_ if is_json => E::HtmlCodeJson,
_ => return None,
},
};
Some(MetaCarrier::Fenced(kind))
}
#[derive(Debug, Clone)]
pub struct Document {
pub path: PathBuf,
pub meta: Value,
pub body: String,
pub carrier: Option<MetaCarrier>,
}
impl Document {
pub fn parse(path: impl Into<PathBuf>, text: &str) -> Result<Self> {
let path = path.into();
if let Some(format) = whole_file_format(&path) {
let meta = meta::parse_value(text, format)?;
return Ok(Self {
path,
meta,
body: String::new(),
carrier: Some(MetaCarrier::WholeFile(format)),
});
}
let (meta, body, carrier) = match fig::detect(text) {
Some(kind) => match fig::split(text, kind) {
Some((content, body)) => (
meta::parse_value(content, kind.inner_format())?,
body.to_owned(),
Some(MetaCarrier::Fenced(kind)),
),
None => (Value::Null, text.to_owned(), None),
},
None => (Value::Null, text.to_owned(), None),
};
Ok(Self {
path,
meta,
body,
carrier,
})
}
pub fn split(text: &str) -> Option<(MetaCarrier, &str, &str)> {
let kind = fig::detect(text)?;
let (meta, body) = fig::split(text, kind)?;
Some((MetaCarrier::Fenced(kind), meta, body))
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn has_meta(&self) -> bool {
self.meta.as_mapping().is_some()
}
pub fn content_attr(&self) -> Option<&str> {
self.meta.get("content").and_then(Value::as_str)
}
pub fn is_attachment(&self) -> bool {
match self.content_attr() {
None => false,
Some(content) => {
self.meta.get("attachment").and_then(Value::as_bool) == Some(true)
|| is_opaque_payload(Path::new(content))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "yaml")]
#[test]
fn parses_yaml_frontmatter_and_body() {
let text = "---\ntitle: Root\ncontents:\n- a.md\n---\n# Body\n\nhello\n";
let doc = Document::parse("index.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
assert_eq!(doc.body, "# Body\n\nhello\n");
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterYaml))
);
assert!(doc.has_meta());
}
#[cfg(feature = "fig-lang")]
#[test]
fn parses_fig_fenced_frontmatter() {
let text = "```fig\ntitle = prov\ncontents = [docs/design.md]\n```\n# Body\n";
let doc = Document::parse("README.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("prov"));
assert_eq!(doc.body, "# Body\n");
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterFig))
);
assert!(doc.has_meta());
}
#[cfg(feature = "json")]
#[test]
fn parses_json_frontmatter() {
let text = ";;;\n{\"title\": \"Root\"}\n;;;\nbody\n";
let doc = Document::parse("note.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterJson))
);
}
#[cfg(feature = "yaml")]
#[test]
fn parses_yaml_endmatter() {
let text = "# Body first\n```endmatter\ntitle: Tail\n```\n";
let doc = Document::parse("note.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Tail"));
assert_eq!(doc.body, "# Body first\n");
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::EndmatterYaml))
);
}
#[cfg(feature = "yaml")]
#[test]
fn a_config_file_is_a_document_whose_content_is_all_metadata() {
let text = "title: ID registry\npart_of: index.md\nregistry:\n abc: a.md\n";
let doc = Document::parse("registry.yaml", text).unwrap();
assert_eq!(
doc.meta.get("title").and_then(Value::as_str),
Some("ID registry")
);
assert_eq!(
doc.meta.get("part_of").and_then(Value::as_str),
Some("index.md")
);
assert_eq!(doc.body, "");
assert_eq!(doc.carrier, Some(MetaCarrier::WholeFile(fig::Format::Yaml)));
assert!(doc.has_meta());
}
#[cfg(feature = "fig-lang")]
#[test]
fn a_fig_config_file_parses_the_dialect() {
let text = "title = settings\npart_of = index.md\n";
let doc = Document::parse("settings.figl", text).unwrap();
assert_eq!(
doc.meta.get("title").and_then(Value::as_str),
Some("settings")
);
assert_eq!(doc.carrier, Some(MetaCarrier::WholeFile(fig::Format::Fig)));
}
#[test]
fn embed_style_config_str_round_trips() {
for style in [
EmbedStyle::Delimited,
EmbedStyle::CodeBlock,
EmbedStyle::HtmlScript,
EmbedStyle::HtmlCode,
EmbedStyle::Separate,
] {
assert_eq!(
EmbedStyle::from_config_str(style.as_config_str()),
Some(style)
);
}
assert_eq!(EmbedStyle::from_config_str("nonsense"), None);
}
#[test]
fn embed_carrier_resolves_style_and_format_to_a_carrier() {
use fig::Format;
let fenced = |k| Some(MetaCarrier::Fenced(k));
assert_eq!(
embed_carrier(EmbedStyle::Delimited, Format::Yaml),
fenced(EmbedType::FrontmatterYaml)
);
assert_eq!(
embed_carrier(EmbedStyle::Delimited, Format::Toml),
fenced(EmbedType::PlusToml)
);
assert_eq!(
embed_carrier(EmbedStyle::Delimited, Format::Json),
fenced(EmbedType::FrontmatterJson)
);
assert_eq!(embed_carrier(EmbedStyle::Delimited, Format::Fig), None);
assert_eq!(
embed_carrier(EmbedStyle::CodeBlock, Format::Fig),
fenced(EmbedType::FrontmatterFig)
);
assert_eq!(
embed_carrier(EmbedStyle::CodeBlock, Format::Yaml),
fenced(EmbedType::FencedYaml)
);
assert_eq!(
embed_carrier(EmbedStyle::HtmlScript, Format::Json),
fenced(EmbedType::HtmlScriptJson)
);
assert_eq!(
embed_carrier(EmbedStyle::HtmlCode, Format::Toml),
fenced(EmbedType::HtmlCodeToml)
);
assert_eq!(
embed_carrier(EmbedStyle::Separate, Format::Yaml),
Some(MetaCarrier::WholeFile(Format::Yaml))
);
assert_eq!(
embed_carrier(EmbedStyle::Separate, Format::Fig),
Some(MetaCarrier::WholeFile(Format::Fig))
);
}
#[test]
fn no_frontmatter_is_all_body() {
let doc = Document::parse("note.md", "# Just a note\n").unwrap();
assert!(doc.meta.is_null());
assert_eq!(doc.body, "# Just a note\n");
assert_eq!(doc.carrier, None);
assert!(!doc.has_meta());
}
#[test]
fn unterminated_fence_is_not_frontmatter() {
let text = "---\ntitle: oops\nno closing fence\n";
let doc = Document::parse("x.md", text).unwrap();
assert!(doc.meta.is_null());
assert_eq!(doc.body, text);
assert_eq!(doc.carrier, None);
}
#[cfg(feature = "yaml")]
#[test]
fn split_borrows_yaml_frontmatter_and_body_without_parsing() {
let text = "---\ntitle: Root\n---\n# Body\n\nhello\n";
let (carrier, meta, body) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterYaml));
assert_eq!(meta, "title: Root\n");
assert_eq!(body, "# Body\n\nhello\n");
let doc = Document::parse("x.md", text).unwrap();
assert_eq!(doc.body, body);
}
#[cfg(feature = "yaml")]
#[test]
fn split_handles_crlf_line_endings() {
let text = "---\r\ntitle: Root\r\n---\r\nbody\r\n";
let (carrier, meta, body) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterYaml));
assert_eq!(meta, "title: Root\r\n");
assert_eq!(body, "body\r\n");
}
#[test]
fn split_is_none_with_no_frontmatter() {
assert_eq!(Document::split("# Just a note\n"), None);
}
#[test]
fn split_is_none_for_an_unterminated_fence() {
let text = "---\ntitle: oops\nno closing fence\n";
assert_eq!(Document::split(text), None);
}
#[cfg(feature = "fig-lang")]
#[test]
fn split_recognizes_a_non_yaml_carrier() {
let text = "```fig\ntitle = prov\n```\n# Body\n";
let (carrier, meta, body) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterFig));
assert_eq!(meta, "title = prov\n");
assert_eq!(body, "# Body\n");
}
#[cfg(feature = "json")]
#[test]
fn split_recognizes_json_frontmatter() {
let text = ";;;\n{\"title\": \"Root\"}\n;;;\nbody\n";
let (carrier, meta, body) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterJson));
assert_eq!(meta, "{\"title\": \"Root\"}\n");
assert_eq!(body, "body\n");
}
#[cfg(feature = "yaml")]
#[test]
fn crlf_fences_are_handled() {
let text = "---\r\ntitle: Root\r\n---\r\nbody\r\n";
let doc = Document::parse("x.md", text).unwrap();
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterYaml))
);
assert_eq!(doc.body, "body\r\n");
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
}
}