use crate::{
Spanned,
grammar::{CoreSchema, FmFormat, FrontmatterConstraint, Only, RuleCommon, States, squash},
text::{self, is_slug, is_url},
};
pub trait Host {
fn scalar_format(&self, name: &str, value: &str) -> Option<Result<(), String>>;
fn reserved_keys(&self) -> &[&str];
}
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
Rule {
rule: RuleCommon,
key: String,
line: Option<usize>,
criterion: &'static str,
message: String,
},
Error {
line: Option<usize>,
criterion: &'static str,
message: String,
},
Gate {
only: Only,
line: usize,
rule: usize,
},
}
struct Fm<'a> {
states: &'a States,
host: &'a dyn Host,
key: String,
events: Vec<Event>,
}
impl Fm<'_> {
fn emit(
&mut self,
rule: &RuleCommon,
line: Option<usize>,
criterion: &'static str,
message: impl Into<String>,
) {
self.events.push(Event::Rule {
rule: rule.clone(),
key: self.key.clone(),
line,
criterion,
message: message.into(),
});
}
fn error(&mut self, line: Option<usize>, criterion: &'static str, message: impl Into<String>) {
self.events.push(Event::Error {
line,
criterion,
message: message.into(),
});
}
fn gate(&mut self, rule: &RuleCommon, line: usize) {
if rule.only.is_gated() {
self.events.push(Event::Gate {
only: rule.only.clone(),
line,
rule: rule.line,
});
}
}
}
pub fn check(
frontmatter: Option<&Spanned>,
schema: &CoreSchema,
states: &States,
host: &dyn Host,
) -> Vec<Event> {
let mut c = Fm {
states,
host,
key: String::new(),
events: Vec::new(),
};
let Some(frontmatter) = frontmatter.filter(|f| f.as_map().is_some()) else {
return c.events;
};
let first_line = Some(frontmatter.line);
for (key, constraint) in &schema.frontmatter {
if !constraint.common.only.admits(c.states) {
continue;
}
let value = frontmatter.get(key);
c.key.clone_from(key);
match value {
None => {
if !constraint.optional {
c.emit(
&constraint.common,
first_line,
"TL_54",
format!("frontmatter key `{key}` is missing"),
);
}
}
Some(v) => {
c.gate(&constraint.common, v.line);
check_value(&mut c, key, constraint, v);
}
}
}
if !schema.frontmatter_open {
let reserved = c.host.reserved_keys();
let written = frontmatter.as_map().unwrap_or(&[]);
for (key, value) in written {
let key = &key.value;
let declared = schema.frontmatter.iter().any(|(k, _)| k == key)
|| schema.lifecycle.iter().any(|d| &d.key == key)
|| reserved.contains(&key.as_str());
if !declared {
c.error(
Some(value.line),
"TL_55",
format!("frontmatter key `{key}` is not declared"),
);
}
}
}
for dim in &schema.lifecycle {
match frontmatter.get(&dim.key) {
None => c.error(
first_line,
"TL_62",
format!("lifecycle key `{}` is missing", dim.key),
),
Some(v) => {
let text = v.scalar_text().unwrap_or_default();
if !dim.states.contains(&text) {
c.error(
Some(v.line),
"TL_108",
format!(
"`{}` must be one of {}; `{text}` is not",
dim.key,
text::code_list(&dim.states, ", ")
),
);
}
}
}
}
c.events
}
fn check_value(c: &mut Fm<'_>, key: &str, constraint: &FrontmatterConstraint, value: &Spanned) {
let common = &constraint.common;
if constraint.list {
let Some(items) = value.as_seq() else {
c.emit(
common,
Some(value.line),
"TL_57",
format!("`{key}` must be a list"),
);
return;
};
if let Some(min) = constraint.min
&& items.len() < min
{
c.emit(
common,
Some(value.line),
"TL_106",
format!(
"`{key}` needs at least {}; it has {}",
text::count(min, "entry", "entries"),
items.len()
),
);
}
if let Some(max) = constraint.max
&& items.len() > max
{
c.emit(
common,
Some(value.line),
"TL_107",
format!(
"`{key}` allows at most {}; it has {}",
text::count(max, "entry", "entries"),
items.len()
),
);
}
for item in items {
check_scalar(c, key, constraint, item);
}
} else {
if value.as_seq().is_some() {
c.emit(
common,
Some(value.line),
"TL_57",
format!("`{key}` must be a scalar, not a list"),
);
return;
}
check_scalar(c, key, constraint, value);
}
}
fn check_scalar(c: &mut Fm<'_>, key: &str, constraint: &FrontmatterConstraint, value: &Spanned) {
let common = &constraint.common;
let line = Some(value.line);
let Some(text) = value.scalar_text() else {
c.emit(common, line, "TL_57", format!("`{key}` must be a scalar"));
return;
};
if let Some(expected) = &constraint.const_
&& &text != expected
{
c.emit(
common,
line,
"TL_56",
format!("`{key}` must be `{expected}`; it is `{text}`"),
);
}
let squashed = squash(&text);
if let Some(shape) = &constraint.shape
&& constraint.id_template.is_none()
&& !shape.matches(&squashed, |_| None)
{
c.emit(
common,
line,
"TL_56",
format!(
"`{key}` `{text}` {}{}",
shape.failure(&squashed),
constraint
.example
.as_deref()
.map_or_else(String::new, |e| format!("; a value of this kind reads {e}"))
),
);
}
if let Some(allowed) = &constraint.enum_
&& !allowed.contains(&text)
{
c.emit(
common,
line,
"TL_56",
format!(
"`{key}` must be one of {}; `{text}` is not",
text::code_list(allowed, ", ")
),
);
}
match &constraint.format {
Some(FmFormat::Host(name)) => {
if let Some(Err(failure)) = c.host.scalar_format(name, &text) {
c.emit(common, line, "TL_56", format!("`{key}` {failure}"));
}
}
Some(FmFormat::Url) if !is_url(&text) => c.emit(
common,
line,
"TL_56",
format!("`{key}` must be an absolute URL; `{text}` is not"),
),
Some(FmFormat::Slug) if !is_slug(&text) => c.emit(
common,
line,
"TL_56",
format!("`{key}` must be a slug; `{text}` is not"),
),
Some(FmFormat::Url | FmFormat::Slug) | None => {}
}
if let Some(template) = &constraint.id_template
&& constraint.const_.is_none()
{
if !template.matches(&text) {
c.emit(
common,
line,
"TL_56",
format!(
"id `{text}` does not match the template `{}`",
template.source
),
);
}
for (name, held) in template.keys_of(&text) {
let Some(state) = c.states.0.get(&name).and_then(|s| s.first()) else {
continue;
};
if &held != state {
c.emit(
common,
line,
"TL_59",
format!("id `{text}` holds `{held}` for `{name}`, but `{name}` is `{state}`"),
);
}
}
}
}