use serde::Deserialize;
use super::grammar::reference_regex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontmatterRef {
pub id: String,
pub path: String,
pub anchor: String,
pub note: Option<String>,
pub line: usize,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum FrontmatterError {
#[error("invalid YAML frontmatter: {0}")]
Yaml(String),
#[error("`spec_refs` must be a list")]
NotAList,
#[error("spec_refs[{0}] must be a map or a string")]
BadEntryKind(usize),
#[error("spec_refs[{index}] is missing required key `{key}`")]
MissingKey {
index: usize,
key: &'static str,
},
#[error("spec_refs[{index}] shorthand does not match the reference grammar: {raw:?}")]
BadShorthand {
index: usize,
raw: String,
},
}
#[derive(Deserialize, Default)]
struct RawFrontmatter {
#[serde(default)]
spec_refs: Option<serde_yaml::Value>,
}
#[must_use]
pub fn has_frontmatter_spec_refs(markdown: &str) -> bool {
let Some((yaml, _)) = extract_frontmatter(markdown) else {
return false;
};
serde_yaml::from_str::<RawFrontmatter>(yaml)
.ok()
.and_then(|f| f.spec_refs)
.is_some_and(|v| !v.is_null())
}
pub fn parse_frontmatter_refs(markdown: &str) -> Result<Vec<FrontmatterRef>, FrontmatterError> {
let Some((yaml, body_start)) = extract_frontmatter(markdown) else {
return Ok(Vec::new());
};
let raw: RawFrontmatter =
serde_yaml::from_str(yaml).map_err(|e| FrontmatterError::Yaml(e.to_string()))?;
let Some(value) = raw.spec_refs else {
return Ok(Vec::new());
};
if value.is_null() {
return Ok(Vec::new());
}
let serde_yaml::Value::Sequence(entries) = value else {
return Err(FrontmatterError::NotAList);
};
let fm_lines: Vec<&str> = yaml.lines().collect();
let mut out = Vec::with_capacity(entries.len());
for (index, entry) in entries.into_iter().enumerate() {
let parsed = parse_entry(&entry, index)?;
let line = locate_line(&fm_lines, body_start, &parsed.id);
out.push(FrontmatterRef { line, ..parsed });
}
Ok(out)
}
fn parse_entry(
entry: &serde_yaml::Value,
index: usize,
) -> Result<FrontmatterRef, FrontmatterError> {
match entry {
serde_yaml::Value::Mapping(map) => {
let get = |k: &'static str| {
map.get(serde_yaml::Value::String(k.to_string()))
.and_then(serde_yaml::Value::as_str)
.map(str::to_string)
};
let id = get("id").ok_or(FrontmatterError::MissingKey { index, key: "id" })?;
let path = get("path").ok_or(FrontmatterError::MissingKey { index, key: "path" })?;
let anchor = get("anchor").ok_or(FrontmatterError::MissingKey {
index,
key: "anchor",
})?;
Ok(FrontmatterRef {
id,
path,
anchor,
note: get("note"),
line: 0,
})
}
serde_yaml::Value::String(raw) => {
let caps =
reference_regex()
.captures(raw)
.ok_or_else(|| FrontmatterError::BadShorthand {
index,
raw: raw.clone(),
})?;
Ok(FrontmatterRef {
id: caps[1].to_string(),
path: caps[2].to_string(),
anchor: caps[3].to_string(),
note: None,
line: 0,
})
}
_ => Err(FrontmatterError::BadEntryKind(index)),
}
}
fn locate_line(fm_lines: &[&str], body_start: usize, id: &str) -> usize {
fm_lines
.iter()
.position(|l| l.contains(id))
.map_or(body_start, |off| body_start + off)
}
fn extract_frontmatter(markdown: &str) -> Option<(&str, usize)> {
let mut lines = markdown.lines();
if lines.next()?.trim_end() != "---" {
return None;
}
let body_off = markdown.find('\n')? + 1;
let rest = &markdown[body_off..];
for (i, line) in rest.lines().enumerate() {
let t = line.trim_end();
if t == "---" || t == "..." {
let end = rest.lines().take(i).map(|l| l.len() + 1).sum::<usize>();
return Some((&rest[..end], 2));
}
}
None
}