use super::params::string_parts;
use super::yaml::{self, Mark, Refused};
use crate::diagnostic::{Code, Diagnostic, Location};
use crate::nearest::nearest;
use serde_json::{Map, Value};
const MAJOR: u64 = 1;
const MINOR: u64 = 0;
pub(crate) const MD_KEYS: [(&str, &str); 9] = [
(
"version",
"the CompMD version: `1`, or a string with a minor version such as `\"1.1\"`",
),
(
"import",
"component imports, each a `from` and the `names` it takes",
),
(
"export",
"component exports: the `names` other documents may import",
),
(
"data",
"data imports, each a `from` and the name `as` gives it under `md.data`",
),
(
"inputs",
"values the reader can change, read as `md.input.<name>`",
),
(
"lang",
"the document's language, a BCP 47 tag; `en` by default",
),
(
"dir",
"the text direction, `ltr`, `rtl` or `auto`; by default the direction of `lang`'s script",
),
(
"tz",
"the time zone of `md.today()`, an IANA name such as `Europe/Berlin`",
),
(
"syntax",
"opt-in syntax the document uses, such as `[wiki-links]`",
),
];
fn md_key_names() -> impl Iterator<Item = &'static str> + Clone {
MD_KEYS.iter().map(|(name, _)| *name)
}
pub(crate) const SYNTAX_NAMES: [&str; 1] = ["wiki-links"];
pub(crate) fn find(text: &str) -> Option<&str> {
let mut lines = lines(text);
let (_, opener) = lines.next()?;
if !is_fence(opener) {
return None;
}
let yaml_start = lines.clone().next().map_or(text.len(), |(start, _)| start);
let mut yaml_end = yaml_start;
for (start, line) in lines {
if is_fence(line) {
return Some(&text[yaml_start..yaml_end.max(yaml_start)]);
}
yaml_end = start + line.len();
}
None
}
fn is_fence(line: &str) -> bool {
line.strip_prefix("---")
.is_some_and(|rest| rest.bytes().all(|b| b == b' ' || b == b'\t'))
}
fn lines(text: &str) -> impl Iterator<Item = (usize, &str)> + Clone {
let mut start = 0;
std::iter::from_fn(move || {
if start >= text.len() {
return None;
}
let rest = &text[start..];
let end = rest.find(['\n', '\r']).unwrap_or(rest.len());
let ending = match &rest.as_bytes()[end..] {
[b'\r', b'\n', ..] => 2,
[] => 0,
_ => 1,
};
let line = (start, &rest[..end]);
start += end + ending;
Some(line)
})
}
pub(crate) fn read(yaml_text: &str, diagnostics: &mut Vec<Diagnostic>) -> Option<yaml::Document> {
match yaml::parse(yaml_text) {
Ok(document) => checked(document, diagnostics),
Err(Refused(mark, why)) => {
diagnostics.push(Diagnostic::new(
Code::F033,
Location::new(1 + mark.line, mark.column + 1),
format!("the frontmatter isn't YAML 1.2 core: {why}; the document is read as plain Markdown"),
));
None
}
}
}
pub(crate) fn metadata(value: &Value, diagnostics: &mut Vec<Diagnostic>) -> Option<yaml::Document> {
checked(
yaml::Document {
value: value.clone(),
..yaml::Document::default()
},
diagnostics,
)
}
fn checked(document: yaml::Document, diagnostics: &mut Vec<Diagnostic>) -> Option<yaml::Document> {
let place = |marks: &std::collections::HashMap<String, Mark>, pointer: &str| {
marks.get(pointer).map_or(Location::new(1, 1), |mark| {
Location::new(1 + mark.line, mark.column + 1)
})
};
let settings = document
.value
.get("md")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let version = settings.get("version");
let Some((MAJOR, minor)) = version.map_or(Some((MAJOR, MINOR)), parse_version) else {
let written = version.map_or_else(String::new, Value::to_string);
diagnostics.push(Diagnostic::new(
Code::F001,
Location::new(1, 1),
format!("unsupported version {written}: this processor implements CompMD {MAJOR}; the document is read as plain Markdown"),
));
return None;
};
if minor > MINOR {
diagnostics.push(Diagnostic::new(
Code::W110,
place(&document.values, "/md/version"),
format!("the document declares version {MAJOR}.{minor}; this processor implements {MAJOR}.{MINOR}"),
));
}
for key in settings
.keys()
.filter(|key| !md_key_names().any(|known| known == key.as_str()))
{
let hint = nearest(key, md_key_names())
.map_or_else(String::new, |known| format!("; did you mean `{known}`?"));
diagnostics.push(Diagnostic::new(
Code::W111,
place(&document.keys, &format!("/md/{key}")),
format!("unknown key `md.{key}`, ignored{hint}"),
));
}
if let Some(Value::Array(names)) = settings.get("syntax") {
for (i, name) in names.iter().enumerate() {
if !name
.as_str()
.is_some_and(|name| SYNTAX_NAMES.contains(&name))
{
diagnostics.push(Diagnostic::new(
Code::W111,
place(&document.values, &format!("/md/syntax/{i}")),
format!("unknown syntax {name} in `md.syntax`, ignored; known: `wiki-links`"),
));
}
}
}
Some(document)
}
pub(crate) fn tree_value(document: &yaml::Document) -> Value {
let mut value = match &document.value {
Value::Object(entries) => Value::Object(entries.clone()),
_ => Value::Object(Map::new()),
};
if let Some(Value::Array(entries)) = value.pointer_mut("/md/data") {
for entry in entries {
if let Some(from) = entry.get("from").and_then(Value::as_str) {
let parts = serde_json::to_value(string_parts(from)).expect("parts serialize");
entry["from"] = parts;
}
}
}
value
}
fn parse_version(value: &Value) -> Option<(u64, u64)> {
let text = match value {
Value::Number(number) => number.to_string(),
Value::String(text) => text.clone(),
_ => return None,
};
let (major, minor) = text.split_once('.').unwrap_or((&text, "0"));
Some((major.parse().ok()?, minor.parse().ok()?))
}
pub(crate) fn wiki_links(settings: &Value) -> bool {
settings
.pointer("/md/syntax")
.and_then(Value::as_array)
.is_some_and(|names| names.iter().any(|name| name == "wiki-links"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn read_all(yaml: &str) -> (Option<Value>, Vec<(Code, Location)>) {
let mut diagnostics = Vec::new();
let parsed = read(yaml, &mut diagnostics).map(|document| tree_value(&document));
let found = diagnostics
.iter()
.map(|d| (d.code, d.range.start))
.collect();
(parsed, found)
}
#[test]
fn finds_frontmatter_between_fences() {
assert_eq!(find("---\na: 1\nb: 2\n---\ntext"), Some("a: 1\nb: 2"));
assert_eq!(find("--- \t\r\na: 1\r\n--- "), Some("a: 1"));
assert_eq!(find("---\n---\n"), Some(""));
assert_eq!(find("---\na: 1"), None);
assert_eq!(find("----\n---"), None);
assert_eq!(find(" ---\n---"), None);
assert_eq!(find(""), None);
}
#[test]
fn version_1_and_later_minors() {
assert_eq!(
read_all("md:\n version: 1"),
(Some(json!({ "md": { "version": 1 } })), Vec::new())
);
assert_eq!(read_all("title: x").1, []);
assert_eq!(
read_all("md:\n version: \"1.2\"").1,
[(Code::W110, Location::new(3, 12))]
);
}
#[test]
fn other_majors_make_the_document_plain() {
for version in ["2", "\"x\"", "[1]", "\"1.x\""] {
assert_eq!(
read_all(&format!("md:\n version: {version}")),
(None, vec![(Code::F001, Location::new(1, 1))]),
"{version}"
);
}
}
#[test]
fn refused_yaml_makes_the_document_plain() {
assert_eq!(
read_all("a: !!str 1"),
(None, vec![(Code::F033, Location::new(2, 4))])
);
}
#[test]
fn unknown_keys_and_syntax_are_w111() {
assert_eq!(
read_all("md:\n input: {}\n syntax: [wiki-links, tables]").1,
[
(Code::W111, Location::new(3, 3)),
(Code::W111, Location::new(4, 24))
]
);
}
#[test]
fn data_imports_split_from_into_parts() {
let (parsed, _) =
read_all("md:\n data:\n - from: ./a-{{md.input.year}}.csv\n - name: x");
assert_eq!(
parsed.unwrap()["md"]["data"],
json!([
{ "from": [
{ "type": "text", "value": "./a-" },
{ "type": "interpolation", "path": "md.input.year", "block": false },
{ "type": "text", "value": ".csv" },
] },
{ "name": "x" },
])
);
}
#[test]
fn frontmatter_that_is_not_a_mapping_has_no_keys() {
assert_eq!(read_all("just text").0, Some(json!({})));
}
#[test]
fn wiki_links_are_opted_in_by_md_syntax() {
assert!(wiki_links(&json!({ "md": { "syntax": ["wiki-links"] } })));
assert!(!wiki_links(&json!({ "md": { "syntax": "wiki-links" } })));
assert!(!wiki_links(&json!({})));
}
}